(FHEM) FTUI 3 Components
grid.components.js
Irgendwie wird die Flaeche durch eine falsche Grid-Anzahl nicht voll ausgenutzt. Zum Testen habe ich in configureGrid() mal folgende Zeilen eingefuegt. Durch (col-1) wurde der Fehler behoben.
cols = (this.cols > 0) ? this.cols : highestCol;
rows = (this.rows > 0) ? this.rows : highestRow;
console.log("Cols: "+cols+" highest: "+highestCol);
console.log("Rows: "+rows+" highest: "+highestRow);
baseWidth = (this.baseWidth > 0) ? this.baseWidth : (this.clientWidth - this.margin) / (cols-1);
baseHeight = (this.baseHeight > 0) ? this.baseHeight : (this.clientHeight - this.margin) / rows;
console.log("baseWidth: "+baseWidth+" calc: "+this.baseWidth+"/"+this.clientWidth+"/"+this.margin);
console.log("baseHeight: "+baseHeight+" calc: "+this.baseHeight+"/"+this.clientHeight+"/"+this.margin);
element.components.js
Deklaration der Basisklasse für alle FTUI-v3-Webcomponents. Jede Komponente wie `ftui-button`, `ftui-label`, `ftui-icon` usw. erbt vermutlich von `FtuiElement`.
Sie kuemmert sich um:
- automatische IDs
- Standardattribute wie `hidden`, `disabled`, `readonly`, `margin`, `padding`
- Verbindung zwischen Attributen und JavaScript-Properties
- Shadow DOM
- Change-Events für FTUI-Bindings
- Hooks für abgeleitete Komponenten
Kurzueberblick
export class FtuiElement extends HTMLElement
`FtuiElement` erweitert den nativen Browser-Typ `HTMLElement`. Dadurch wird daraus die gemeinsame Basis für eigene HTML-Tags wie:
<ftui-button></ftui-button> <ftui-label></ftui-label>
Import
import { isNumeric, toKebabCase, log } from '../modules/ftui/ftui.helper.js';
Es werden drei Hilfsfunktionen verwendet:
- isNumeric() prüft, ob ein Wert numerisch ist
- toKebabCase() wandelt z.B. "baseWidth" in "base-width"
- log() schreibt Debug-Ausgaben
UID-Zähler
const uids = {};
Damit bekommt jedes FTUI-Element automatisch eine ID, falls keine gesetzt wurde.
Beispiel:
<ftui-label></ftui-label>
wird intern etwa zu:
<ftui-label id="ftui_label_1"></ftui-label>
Constructor
constructor(properties) {
super();
`super()` ruft den Konstruktor von `HTMLElement` auf. Das ist bei Webcomponents Pflicht.
if (!this.id) {
...
this.id = `${this.localName.replace(/-/g, '_')}_${uids[this.localName]++}`;
}
Falls das Element keine ID hat, wird automatisch eine erzeugt.
this.properties = Object.assign(FtuiElement.properties, properties);
Hier werden die Standard-Properties der Basisklasse mit den Properties der konkreten Komponente gemischt.
Beispiel:
FtuiElement.properties = {
hidden: false,
disabled: false,
readonly: false,
margin: '',
padding: ''
}
Ein `ftui-button` könnte zusätzlich `value`, `color`, `fill` usw. definieren.
Achtung: `Object.assign(FtuiElement.properties, properties)` verändert das Objekt `FtuiElement.properties`. Sauberer wäre oft:
Object.assign({}, FtuiElement.properties, properties)
Sonst können Properties versehentlich global vermischt werden.
this.initProperties(this.properties);
Erzeugt für jede Property Getter/Setter und initialisiert die passenden HTML-Attribute.
if (typeof this.template === 'function') {
this.createShadowRoot(this.template());
}
Wenn die konkrete Komponente eine Methode `template()` besitzt, wird daraus ein Shadow DOM erzeugt.
this.isActiveChange = {};
Merker für aktive Änderungen, wichtig für Two-Way-Binding.
if (window.ftuiApp) {
ftuiApp.attachBinding(this);
}
Wenn die FTUI-App bereits existiert, wird das Element mit dem FTUI-Binding-System verbunden.
Shadow DOM
createShadowRoot(content) {
const elemTemplate = document.createElement('template');
elemTemplate.innerHTML = content;
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(elemTemplate.content.cloneNode(true));
}
Diese Methode baut das interne DOM der Komponente.
Beispiel:
template() {
return `<button><slot></slot></button>`;
}
wird zu einem Shadow DOM innerhalb des Elements.
`mode: 'open'` bedeutet: Man kann von außen per JavaScript darauf zugreifen:
element.shadowRoot
Standard-Properties
static get properties() {
return {
hidden: false,
disabled: false,
readonly: false,
margin: '',
padding: '',
};
}
Jedes FTUI-Element bekommt diese Attribute:
<ftui-button hidden></ftui-button> <ftui-button disabled></ftui-button> <ftui-button readonly></ftui-button> <ftui-button margin="1"></ftui-button> <ftui-button padding="0.5"></ftui-button>
observedAttributes
static get observedAttributes() {
return [...Object.keys(FtuiElement.properties)];
}
Der Browser ruft `attributeChangedCallback()` nur für Attribute auf, die hier stehen.
Wichtig: Hier werden nur die Basiseigenschaften beobachtet. Abgeleitete Komponenten müssen wahrscheinlich selbst `observedAttributes` erweitern, sonst werden ihre eigenen Attribute nicht automatisch beobachtet.
convertToAttributes
static convertToAttributes(properties) {
return Object.keys(properties).map(property => toKebabCase(property));
}
Hilfsfunktion, um Property-Namen in HTML-Attributnamen umzuwandeln.
Beispiel:
baseWidth
wird zu:
base-width
connectedCallback
connectedCallback() {
this.updateProperties();
if (typeof this.onConnected === 'function') {
this.onConnected();
}
}
Diese Methode wird automatisch vom Browser aufgerufen, wenn das Element in die Seite eingefügt wird.
Ablauf:
1. Standardwerte werden verarbeitet.
2. Falls die konkrete Komponente `onConnected()` definiert, wird diese Hook-Funktion ausgeführt.
Beispiel in einer Kindklasse:
onConnected() {
console.log('Button ist im DOM');
}
attributeChangedCallback
attributeChangedCallback(name, oldValue, newValue) {
Wird aufgerufen, wenn ein beobachtetes Attribut geändert wird.
log(3, `${this.id} - attributeChangedCallback ...`)
Debug-Ausgabe.
if (typeof this.onAttributeChanged === 'function') {
this.onAttributeChanged(name, newValue, oldValue);
}
Hook für Kindklassen.
Danach behandelt die Basisklasse ihre Standardattribute:
- ==hidden
case 'hidden': this.style.display = hasValue ? 'none' : '';
Wenn `hidden` gesetzt ist, wird das Element ausgeblendet.
- ==disabled
case 'disabled': this.style.filter = hasValue ? 'sepia(1) saturate(0) blur(1px)' : ''; this.style.pointerEvents = hasValue ? 'none' : '';
Optisch ausgegraut und nicht anklickbar.
- ==readonly
case 'readonly': this.style.pointerEvents = hasValue ? 'none' : '';
Nicht bedienbar, aber ohne optischen Filter.
- ==margin
case 'margin':
if (this.tagName !== 'FTUI-GRID') {
this.style.margin = isNumeric(newValue) ? newValue + 'em' : newValue;
}
Wenn numerisch:
margin="1"
wird zu:
margin: 1em;
Wenn nicht numerisch:
margin="10px"
bleibt:
margin: 10px;
Für `FTUI-GRID` wird `margin` hier nicht gesetzt, vermutlich weil `ftui-grid` sein eigenes Margin-Verhalten hat.
- ==padding
case 'padding': this.style.padding = isNumeric(newValue) ? newValue + 'em' : newValue;
Analog zu `margin`.
submitChange
submitChange(property, value) {
this.isActiveChange[property] = true;
this[property] = value;
this.emitChangeEvent(property, value);
}
Das ist wichtig für FTUI-Bindings.
Wenn eine Komponente intern einen Wert ändert, etwa ein Button oder Slider, ruft sie:
this.submitChange('value', newValue);
Dann passiert:
1. Änderung wird als aktiv markiert. 2. Property wird gesetzt. 3. Ein Event `valueChange` wird ausgelöst.
Dieses Event kann FTUI dann nutzen, um per `(value)` oder `[(value)]` etwas an FHEM zu senden.
emitChangeEvent und emitEvent
emitChangeEvent(attribute, value) {
this.emitEvent(attribute + 'Change', value);
}
Aus `value` wird:
text
valueChange
emitEvent(name, value) {
const event = new CustomEvent(name, { detail: value });
this.dispatchEvent(event);
}
Es wird ein normales DOM-Event ausgelöst.
Beispiel:
this.emitChangeEvent('value', 'on');
erzeugt:
new CustomEvent('valueChange', { detail: 'on' })
initProperties
initProperties(properties) {
Object.entries(properties).forEach(([name, defaultValue]) => {
Für jede Property wird automatisch ein passender Getter/Setter erzeugt.
Beispiel:
hidden: false
wird zu:
element.hidden
und HTML-Attribut:
hidden
- ==Boolean
if (typeof properties[name] === 'boolean') {
this.defineBooleanProperty(name, attr);
this.initBooleanAttribute(attr, defaultValue);
}
Boolean-Attribute funktionieren nach HTML-Logik:
<ftui-button disabled></ftui-button>
bedeutet `true`.
- ==Number
else if (typeof properties[name] === 'number') {
this.defineNumberProperty(name, attr);
this.initAttribute(attr, defaultValue);
}
Wird beim Lesen in `Number(...)` umgewandelt.
- ==String
else {
this.defineStringProperty(name, attr);
this.initAttribute(attr, defaultValue);
}
Normale String-Attribute.
initAttribute
initAttribute(attr, value) {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
}
Wenn das Attribut noch nicht im HTML steht, wird der Default gesetzt.
Beispiel:
padding: ''
führt zu:
padding=""
initBooleanAttribute
initBooleanAttribute(attr, value) {
if (!this.hasAttribute(attr) && value) {
this.setAttribute(attr, '');
}
}
Boolean-Attribute werden nur gesetzt, wenn der Default `true` ist.
Da `hidden`, `disabled`, `readonly` alle `false` sind, werden sie standardmäßig nicht gesetzt.
defineBooleanProperty
Object.defineProperty(this, name, {
get() {
return this.hasAttribute(attr)
&& this.getAttribute(attr) !== 'false';
},
set(value) {
if (value) {
this.setAttribute(attr, '');
} else {
this.removeAttribute(attr);
}
},
});
Damit kann man schreiben:
element.disabled = true;
und bekommt im HTML:
<ftui-button disabled></ftui-button>
Bei:
element.disabled = false;
wird das Attribut entfernt.
Interessant:
disabled="false"
wird als `false` interpretiert. Das ist etwas komfortabler als natives HTML, wo ein vorhandenes Boolean-Attribut normalerweise immer wahr ist.
defineNumberProperty
get() { return Number(this.getAttribute(attr)); },
set(value) { this.setAttribute(attr, value); },
Beispiel:
<ftui-grid-tile row="2"></ftui-grid-tile>
element.row
liefert Zahl `2`.
defineStringProperty
get() { return this.getAttribute(attr); },
set(value) { this.setAttribute(attr, value); },
Normale Abbildung:
element.text = 'Hallo';
wird zu:
text="Hallo"
updateProperties
updateProperties() {
Object.entries(this.properties).forEach(([name, defaultValue]) => {
const attr = toKebabCase(name);
if (this.getAttribute(attr) === String(defaultValue)) {
this.attributeChangedCallback(attr, null, defaultValue);
}
})
}
Beim Einfügen ins DOM werden Standardattribute nochmal verarbeitet.
Warum? Wenn ein Attribut schon beim Erzeugen gesetzt wurde, kann es sein, dass die Style-Logik noch nicht gelaufen ist. `updateProperties()` erzwingt deshalb die Behandlung der Defaultwerte.
Kommentierte Version
import { isNumeric, toKebabCase, log } from '../modules/ftui/ftui.helper.js';
// Zähler für automatisch erzeugte IDs pro Elementtyp
const uids = {};
// Basisklasse aller FTUI-Webcomponents
export class FtuiElement extends HTMLElement {
constructor(properties) {
super();
// Falls keine ID vorhanden ist, automatisch eine eindeutige ID erzeugen
if (!this.id) {
if (!uids[this.localName]) {
uids[this.localName] = 1;
}
this.id = `${this.localName.replace(/-/g, '_')}_${uids[this.localName]++}`;
}
// Standardproperties der Basisklasse mit Komponentenproperties kombinieren
this.properties = Object.assign(FtuiElement.properties, properties);
// Für alle Properties Getter/Setter und Default-Attribute anlegen
this.initProperties(this.properties);
// Wenn die Komponente ein Template besitzt, Shadow DOM erzeugen
if (typeof this.template === 'function') {
this.createShadowRoot(this.template());
}
// Merker für aktive Änderungen, wichtig für Output-/Two-Way-Bindings
this.isActiveChange = {};
// Element beim globalen FTUI-Binding-System anmelden
if (window.ftuiApp) {
ftuiApp.attachBinding(this);
}
}
// Erzeugt Shadow DOM aus einem HTML-Template-String
createShadowRoot(content) {
const elemTemplate = document.createElement('template');
elemTemplate.innerHTML = content;
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(elemTemplate.content.cloneNode(true));
}
// Standardattribute, die jedes FTUI-Element besitzt
static get properties() {
return {
hidden: false,
disabled: false,
readonly: false,
margin: '',
padding: '',
};
}
// Attribute, bei deren Änderung attributeChangedCallback ausgelöst wird
static get observedAttributes() {
return [...Object.keys(FtuiElement.properties)];
}
// Wandelt Property-Namen in HTML-Attributnamen um
static convertToAttributes(properties) {
return Object.keys(properties).map(property => toKebabCase(property));
}
// Wird vom Browser aufgerufen, wenn das Element in den DOM eingefügt wurde
connectedCallback() {
this.updateProperties();
// Hook für abgeleitete Komponenten
if (typeof this.onConnected === 'function') {
this.onConnected();
}
}
// Wird aufgerufen, wenn sich ein beobachtetes Attribut ändert
attributeChangedCallback(name, oldValue, newValue) {
log(3, `${this.id} - attributeChangedCallback name=${name}, oldValue=${oldValue}, newValue=${newValue}`);
// Hook für abgeleitete Komponenten
if (typeof this.onAttributeChanged === 'function') {
this.onAttributeChanged(name, newValue, oldValue);
}
const hasValue = newValue !== null && newValue !== false;
// Standardattribute behandeln
switch (name) {
case 'hidden':
this.style.display = hasValue ? 'none' : '';
break;
case 'disabled':
this.style.filter = hasValue ? 'sepia(1) saturate(0) blur(1px)' : '';
this.style.pointerEvents = hasValue ? 'none' : '';
break;
case 'readonly':
this.style.pointerEvents = hasValue ? 'none' : '';
break;
case 'margin': {
// ftui-grid behandelt margin selbst
if (this.tagName !== 'FTUI-GRID') {
this.style.margin = isNumeric(newValue) ? newValue + 'em' : newValue;
}
break;
}
case 'padding': {
this.style.padding = isNumeric(newValue) ? newValue + 'em' : newValue;
break;
}
}
}
// Wird von Komponenten genutzt, wenn ein Benutzerwert geändert wurde
submitChange(property, value) {
this.isActiveChange[property] = true;
this[property] = value;
this.emitChangeEvent(property, value);
}
// Erzeugt z.B. valueChange aus value
emitChangeEvent(attribute, value) {
this.emitEvent(attribute + 'Change', value);
}
// Löst ein DOM CustomEvent aus
emitEvent(name, value) {
const event = new CustomEvent(name, { detail: value });
this.dispatchEvent(event);
}
// Initialisiert Properties abhängig vom Typ des Defaultwerts
initProperties(properties) {
Object.entries(properties).forEach(([name, defaultValue]) => {
const attr = toKebabCase(name);
if (typeof properties[name] === 'boolean') {
this.defineBooleanProperty(name, attr);
this.initBooleanAttribute(attr, defaultValue);
} else if (typeof properties[name] === 'number') {
this.defineNumberProperty(name, attr);
this.initAttribute(attr, defaultValue);
} else {
this.defineStringProperty(name, attr);
this.initAttribute(attr, defaultValue);
}
});
}
// Setzt Default-Attribut, wenn es noch nicht existiert
initAttribute(attr, value) {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
}
// Setzt Boolean-Attribut nur, wenn Default true ist
initBooleanAttribute(attr, value) {
if (!this.hasAttribute(attr) && value) {
this.setAttribute(attr, '');
}
}
// Definiert Boolean-Property mit HTML-Attribut-Synchronisierung
defineBooleanProperty(name, attr) {
Object.defineProperty(this, name, {
get() {
return this.hasAttribute(attr)
&& this.getAttribute(attr) !== 'false';
},
set(value) {
if (value) {
this.setAttribute(attr, '');
} else {
this.removeAttribute(attr);
}
},
});
}
// Definiert Number-Property
defineNumberProperty(name, attr) {
Object.defineProperty(this, name, {
get() {
return Number(this.getAttribute(attr));
},
set(value) {
this.setAttribute(attr, value);
},
});
}
// Definiert String-Property
defineStringProperty(name, attr) {
Object.defineProperty(this, name, {
get() {
return this.getAttribute(attr);
},
set(value) {
this.setAttribute(attr, value);
},
});
}
// Behandelt Defaultwerte beim Einfügen des Elements in den DOM
updateProperties() {
Object.entries(this.properties).forEach(([name, defaultValue]) => {
const attr = toKebabCase(name);
if (this.getAttribute(attr) === String(defaultValue)) {
this.attributeChangedCallback(attr, null, defaultValue);
}
});
}
}
Mögliche Schwachstellen / Besonderheiten
1. **`Object.assign(FtuiElement.properties, properties)` mutiert die statischen Basiseigenschaften.**
Sicherer wäre:
this.properties = Object.assign({}, FtuiElement.properties, properties);
2. **`observedAttributes` enthält nur Basiseigenschaften.**
Kindklassen müssen eigene Attribute selbst hinzufügen.
3. **Boolean-Erkennung `newValue !== false` ist etwas ungenau.**
Attribute liefern normalerweise Strings oder `null`, nicht echtes `false`. Bei `disabled="false"` funktioniert der Getter zwar korrekt, aber `attributeChangedCallback()` behandelt `"false"` trotzdem als vorhanden.
4. **`initAttribute()` setzt auch leere Default-Strings als Attribute.**
Dadurch entstehen Attribute wie:
margin="" padding=""
Das ist nicht falsch, aber kann unnötige `attributeChangedCallback()`-Aufrufe erzeugen.
5. **`createShadowRoot()` nutzt `innerHTML`.**
Das ist normal für Templates, aber Template-Inhalte sollten kontrolliert sein.
Merksatz
Dieses Programm macht aus einer normalen Webcomponent eine **FTUI-kompatible Komponente mit Attributbindung, Defaultwerten, Shadow DOM und Change-Events**.