(FHEM) FTUI 3 Components

Aus TippvomTibb
Zur Navigation springen Zur Suche springen

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:

  1. ==hidden
case 'hidden':
  this.style.display = hasValue ? 'none' : '';

Wenn `hidden` gesetzt ist, wird das Element ausgeblendet.

  1. ==disabled
case 'disabled':
  this.style.filter = hasValue ? 'sepia(1) saturate(0) blur(1px)' : '';
  this.style.pointerEvents = hasValue ? 'none' : '';

Optisch ausgegraut und nicht anklickbar.

  1. ==readonly
case 'readonly':
  this.style.pointerEvents = hasValue ? 'none' : '';

Nicht bedienbar, aber ohne optischen Filter.

  1. ==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.

  1. ==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
  1. ==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`.

  1. ==Number
else if (typeof properties[name] === 'number') {
  this.defineNumberProperty(name, attr);
  this.initAttribute(attr, defaultValue);
}

Wird beim Lesen in `Number(...)` umgewandelt.

  1. ==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**.