Samstag, 27. Mai 2017

Anwendungen mit HTML-UI

HTML - ein UI-Format, das bleiben wird
Zum Beispiel ein Property Editor
Ein Rückblick
Der HTML-Code der Anwendung
Anzeige- und Änderungsmodus
Model View Controller
Buttons
Feldauswahl
Tabellen
Editierbarkeit mit contenteditable
Einen Wert löschen
Die node-webkit-Anwendung
Dateiauswahl
Das Datenmodell - die Klasse Properties
Der Dateivergleich
Unit Tests

HTML - ein UI-Format, das bleiben wird

In klassischen Entwicklungsumgebungen werden dem Anwendungsentwickler meist spezielle Werkzeuge für die Entwicklung der Oberfläche bereitgestellt: er darf aus irgendwelchen standardisierten Paletten Eingabecontrols wie Tabellen, Eingabefelder, Auswahlknöpfe, Schaltflächen auswählen und auf einen Oberflächentwurf ziehen, er darf Beschriftungen und sonstige Texte vorsehen, er darf Bereiche und Feldgruppen definieren, er darf per Mausclick Feldinhalte an sein Datenmodell binden und vom Control ausgelöste Ereignisse mit seinem Code verknüpfen usw.

Solche Werkzeuge sind ja sicher ganz nett (wobei ich meist als erstes auf ihre Unvollkommenheiten, auf schlechte Benutzerführung und auf fehlende Features stoße), aber letztlich muß eine so entworfene Oberfläche in einem maschinenlesbaren Format abgespeichert werden, das die Anwendung nachher zum Rendern benutzt. Die für Entwickler interessante Frage ist daher nicht nach dem UI und den Regeln, die ihm seine Entwicklungsumgebung zum Malen erlaubt, sondern nach der vollständigen Referenz über alle Möglichkeiten, die das konkrete Format, wie derartige Oberflächen dann gespeichert werden, bietet.

Nun gibt es ein maschinenlesbares, lebendiges, sehr flexibles, umfassend dokumentiertes, an Möglichkeiten unglaublich facettenreiches und noch immer weiter wachsendes Format zur Entwicklung von Benutzeroberflächen: es handelt sich um die Sprachtriade HTML, CSS und JavaScript. Durch die mittlerweile weitgehend standardkonforme Implementierung in allen gängigen Browsern haben diese Formate eine breite Basis. Auch auf Kleingeräten haben sie sich längst durchgesetzt. Man kann die CSS-Stilregeln der Oberflächen mit wenig Aufwand so gestalten, daß derselbe HTML-Code je nach Größe des Displays unterschiedlich dargestellt wird. Und wen die Browser-Sandbox zu sehr einschränkt, der kann seine App oder nativ entwickelte Anwendung mit einem HTML-Viewer ausrüsten, um die Oberfläche in seiner eigenen Umgebung zu präsentieren.

Die Webstandards leben und werden weiterentwickelt - die Entwicklungen der letzten Jahre sind ermutigend. HTML5, CSS3 und ES6 werden von den User-Agents dieser Welt mittlerweile doch tatsächlich so verstanden, wie es spezifiziert wurde![3]

Zum Beispiel ein Property Editor

Ich will in diesem Blog eine Anwendung zur Anzeige und Pflege von Property Files vorstellen, wobei ich die vielen kleinen Dinge en detail bespreche, die bei so einer Oberfläche auftreten. Die Entwicklung folgt den ES6-Standards für JavaScript. Die Anwendung kann in einem Browser aufgerufen werden, dann fungiert sie aber als reine Anzeigetransaktion, da ein Browser es nicht erlaubt, Dateien zu sichern. Dieselbe Anwendung - derselbe HTML-,CSS- und JavaScript-Code kann aber auch als node-webkit-Anwendung aufgerufen werden, dann können Daten auch editiert und Änderungen gespeichert werden.

Property Files werden gern als sprachspezifische Textressourcen für eine Anwendung eingesetzt, daher kann man einen Property Editor in der Rurik Internationalisierung einordnen. Da sie manuell als Klartextdateien bearbeitet werden, ist ein Werkzeug nützlich, das die Schlüssel/Wert-Paare mehrerer Property Files miteinander vergleicht, so dass fehlende oder mehrfach gepflegte Werte schnell erkannt werden.

Hier ein Screenshot der fertigen Anwendung, deren Quelltextdateien ich bei github mit dem Repositorynamen property-editor führe.

Ein Rückblick

Vor sieben Jahren stellte ich auf diesem Blog einige Konzepte für die Pflege von tabellenförmigen Daten in einer Webanwendung vor. Ich hatte dazu einen kleinen, nach wie vor lauffähigen Prototypen verfaßt, an dem die Konzepte sichtbar werden sollten.

Meine Absicht war, ohne irgendwelche magischen Tools und Bibliotheken auszukommen, sondern möglichst direkt die Standards des Webs zu verwenden - und das sind nach wie vor HTML, CSS und JavaScript, weiter nichts - um die Tabellenpflege zu entwerfen. [1]

Die Änderungsvormerkungen (was muss beim späteren Druck auf Sichern geändert, was gelöscht, was eingefügt werden) hatte ich mittels CSS-Klassen direkt im HTML-DOM verwaltet, so daß ein und dieselben dynamisch vergebenen CSS-Markierungen nicht nur beim Sichern vom JavaScript-Code abgegrast werden, um die zu ändernden Daten für den Server zu sammeln, sondern jederzeit auch dem Benutzer den Änderungszustand seiner Daten vor Augen führen:

Die Kommunikation mit dem Server - zum Lesen und Abspeichern der Daten - führte ich damals schon mit dem JSON-Format durch, das mittlerweile in Webanwendungen omnipräsent ist und das etwas schwerfälligere XML weitgehend abgelöst hat.[2]

Das konkrete Datenformat dient als level of indirection dazu, die Benutzeroberfläche zu entkoppeln: auf der Gegenseite stand für meinen Prototypen minimalistisch gehaltener Perl-Code zum Parsen von JSON und zum Verwalten der Daten in einer CSV-Datei. Es könnte aber auch eine ganz andere Implementierung zum Einsatz kommen, in einer anderen Programmiersprache, nicht per CGI oder mit einem anderen Server aufgerufen (tatsächlich läuft die Beispielapplikation mittlerweile nicht mehr auf Apache, sondern auf nginx), mit einem anderen Datenformat für die Persistenzebene usw.

Der HTML-Code der Anwendung

Das Markup der Anwendung definiert feste Bestandteile und Container für dynamisch gefüllte Inhalte.

Durch die Wechselwirkung mit JavaScript verschwimmt die Grenze zwischen "statischem" HTML und HTML-Template. Das ist so gewollt. Wo allerdings JavaScript HTML-Code in größerem Stil generieren soll, weit über bloße Strukturtags hinaus (wie etwa <table>, <tr> und <td> zur Strukturierung von Tabellendaten), empfiehlt sich der Einsatz von Templates. Hierzu wird man in HTML5 durch das neu eingeführte <template-Tag ermutigt. Der Einsatz hochkomplexer Parsergeneratoren wie handlebars ist zweifellos spannend, aber meist überdimensioniert, wo der standardmäßige DOM-Zugriff bereits ausreicht, um konkrete Instanzen des Templates zu erzeugen, in das HTML-DOM zu importieren und einzufügen.

In unserer Beispielapplikation wird kein Template benötigt. Was hier dynamisch erzeugt wird, ist nur die Tabelle mit den Schlüsseln und Werten, ohne ausgefeilte Extras. Mehr zur Tabellendarstellung folgt weiter unten.

Viele überflüssige Schlacken können in HTML5 weggelassen werden, so die Angaben type="text/css" in Stylesheet-Referenzen und type="text/javascript" in JavaScript-Elementen, denn dies sind die Defaults. Worauf man aber nicht verzichten sollte, ist die Angabe der Zeichencodierung. Ausdrücklich warnt das W3C:

You should always specify a character encoding on every HTML document, or bad things will happen. You can do it the hard way (HTTP Content-Type header), the easy way ( declaration), or the new way ( attribute), but please do it. The web thanks you.

Nach dem Laden auszuführendes JavaScript muß überhaupt nicht mehr an Events wie load oder DOMContentLoaded gebunden werden, sondern kann direkt vor das schließende </body>-Element eingebaut werden. Dann ist der HTML-Code bereits vollständig in die DOM-Struktur transformiert, so daß alle gewünschten, die erste Anzeige des Dokuments vorbereitenden Aktionen noch ausgeführt werden können.

Aus diesen Grundsätzen abgeleitet, bekommt das für den Property-Editor benötigte index.html-Dokument schließlich die folgende Gestalt:

<!DOCTYPE html>
<html language="en">
  <head>
   <meta charset="utf-8"/>
   <title>Comparing Property Files</title>
   <link rel="stylesheet" href="main.css" />
   <script src="ui.js"></script>
    <script src="i18n.js"></script>
  </head>
  <body>

    <h1>Comparing Property Files</h1>
   
    <div class="input-area">
      <div>
        <input type="file" multiple id="property-files">
        <label class="button" for="property-files" data-action="choose-files">
          Choose files
        </label>
        <div class="additional-info" id="selected-files">No files selected</div>
      </div>
      <div id="toolbar">
        <button data-action="reload">Reload</button>
        <button data-action="save">Save</button>       
      </div>
    </div>

    <div id="table-container"></div>
  
    <script src="control.js"></script>
  </body>
</html>

Anzeige- und Änderungsmodus

Dasselbe UI soll - als Webanwendung im Browser aufgerufen - ein reines Hilfsmittel zur Anzeige und zum Vergleich von Property Files sein, aber in einer geeigneten Umgebung, die auch das Speichern erlaubt (wie etwa node-webkit), im Änderungsmodus betrieben werden können.

Diese Unterscheidung mache ich, indem ich im Querystring der URL den Wert edit vermerke, also z.B. index.html?edit aufrufe statt bloß index.html. Dieser wird im Client ausgewertet und ist danach als Konstante im gesamten JavaScript control.js verfügbar:

   const editMode       = /\bedit\b/.test(document.location.search)
Den Anzeigemodus erhalte ich, wenn ich die URL ohne Queryteil aufrufe:
http://ruediger-plantiko.net/property-editor/
Dieselbe Webseite präsentiert sich im Änderungsmodus, wenn ich im Queryteil das Wort edit übergebe:
http://ruediger-plantiko.net/property-editor/?edit

Der augenscheinlichste Unterschied, daß man sich im Änderungsmodus befindet, ist die Anwesenheit eines (wenn auch anfangs noch inaktiven) Save-Buttons. Wenn man Property-Files eingelesen hat, zeigen sich weitere Unterschiede: die Zellen sind editierbar, Zellen, Schlüssel und ganze Zeilen sind auch löschbar. Die komplette Oberfläche für den Änderungsmodus funktioniert im Browser - mit der einen, entscheidenden Ausnahme der Save-Funktion. Denn einem Browser ist es nicht erlaubt, Dateien auf dem Computer des Benutzers zu speichern. Das wird nur möglich, wenn wir dasselbe UI in Form einer node-webkit-Anwendung betreiben. Mehr dazu weiter unten.

Diesen Weg - mit dem edit-Parameter im Querystring - habe ich gewählt, weil sich Anzeige- und Änderungsmodus nur geringfügig unterscheiden. Bei größeren Unterschieden würde ich zwei separate HTML-Dokumente entwerfen, eines für den Anzeige-, eines für den Änderungsmodus. Das JavaScript läßt sich trotzdem wiederverwenden, und es kann vom HTML-Dokument den gewünschten Modus als Attribut des <script>-Elements auslesen. Beispielsweise könnte man den Änderungsmodus als Boolesches Attribut data-edit entwerfen (seine Präsenz möge den Änderungsmodus anzeigen):

<script src="control.js" data-edit>
Im Script selbst kann man dann zu Beginn dieses Attribut einlesen (document.currentScript enthält immer das <script>-Element des gerade ausgeführten JavaScript-Codes):
const editMode = document.currentScript.hasAttribute("data-edit")

Model View Controller

Das MVC-Paradigma ist im Grunde nichts weiter als eine praktische Anwendung von separation of concerns. Der Kerm von MVC ist die Trennung der "eigentlichen Logik" (Model) von den für den Betrieb einer Benutzeroberfläche notwendigen Programmteilen (View, Controller).

Für diese Entkopplung ist nicht immer ein ausgefeiltes MVC-Framework nötig: in einfachen Fällen genügt es, die eigentliche Anwendungslogik in ein eigenes UI-freies Softwaremodul zu verlegen, das von einem einzigen Controllermodul control.js aus aufgerufen wird. Dieses Controllermodul ist seinerseits nicht nur mit dem Awendungsmodul, sondern auch mit der konkreten Oberfläche gekoppelt. Es reagiert auf Eingaben und sonstige Dialog-Ereignisse, ruft abhängig von diesen Ereignissen Anwendungslogik auf und aktualisiert dann die Oberfläche, was etwa aufgrund geänderter Anwendungsdaten nötig wird.

Statt einen Ereignisbus als weitere Komponente zu entwerfen, mit dem sich Models und UI-Methoden für "change"-Events registrieren können, kann man im einfachsten Fall die gewünschten Reaktionen auch direkt als Funktionsaufruf implementieren. Eine Funktion eines anderen Moduls aufzurufen, ist immerhin auch eine Benachrichtigung. Ein Event Bus kann nützliche Dienste zur Entkopplung leisten - vor allem, wenn zur Laufzeit mehrere Models von wechselndem Objekttyp involviert sind. In einfachen Fällen sollte man aber auch einfache Lösungen wählen – hier also die fest verdrahteten Zusammenhänge.

Die Anwendungslogik des Property Editors enthält das Modul i18n. Das Controllermodul heißt control.js. Potentiell wiederverwendbare Funktionen im Bereich des HTML-UI befinden sich im Modul ui.js. Die konkrete Oberfläche selbst besteht aus einem HTML-File index.html und einem CSS-File main.css.

Buttons

Als das entscheidende, einen Button definierende Merkmal dürfte unwidersprochen gelten, daß man ihn drücken kann!

Damit dieses Drücken auch Sinn und Zweck hat, sollte mit dem Druck eine Aktion verbunden sein. Auf HTML-DOM-Ebene bedeutet dies, daß eine Funktion für das click-Event eines Buttons registriert ist. Nun können mehrere Buttons dieselbe Aktion auslösen, z.B. Blätterbuttons, die aus Usability-Gründen oberhalb und unterhalb einer Tabelle angeboten werden.

Um einen Button als für eine bestimmte Aktion zuständig zu erklären, könnte man ihm im HTML-Code ein Custom-Attribut data-action geben (Custom-Attribute sollen immer mit dem Präfix data- versehen werden), das ausdrückt, welche Aktion er auslösen soll. Üblich ist außerdem, Buttons in einem gemeinsamen Bereich, einer Toolbar zu plazieren:

<div id="toolbar">
  <button data-action="reload">Reload</button>
  <button data-action="save">Save</button>        
</div>
Im Controller verknüpfen wir die Aktionen mit ihren Behandlerfunktionen, wobei die eigentliche Realisierung an den allgemeinen UI-Modul ui.js delegiert wird:
// -------------------------------------------------------------------
// Define handlers for user events
// -------------------------------------------------------------------
   function setHandlers() {
     var clickHandlers = {
        "reload": handleReload,
        "save": handleSave
     }
     ui.registerHandlers("click",clickHandlers)
     inputFiles.addEventListener("change",handleChooseFiles)
     
     tableContainer.addEventListener("input",handleInput)
     if (editMode) tableContainer.addEventListener("mouseover",handleOnMouseOver)
     
   }
Hier haben wir eine Funktion setHandlers(), die für alle möglichen User-Events die Behandlerfunktionen definiert - nicht nur für die Buttons, sondern auch für das Dateiauswahlfeld, das hier eine Sonderrolle spielt, für Textänderungen in den Datenzellen, und für das mouseover-Event, wenn der Mauszeiger über Tabellenzellen bewegt wird. Für die Buttons reload und save wird, wie erwähnt, die allgemeine Logik aus ui.js verwendet.

Die allgemeine Funktion registerHandlers bekommt für ein bestimmtes Event, z.B. click, einen JavaScript-Hash von Aktionen und zugeordneten Behandlerfunktionen übergeben. registerHandlers ermittelt aus dem Aktionskey wie save einen CSS-Selector wie [data-action="save"] und verwendet diesen, um alle Elemente zu ermitteln, die für das Event registriert werden sollen. In der Loop über die Elemente wird die Standardfunktion addEventListener benutzt, um für jedes Element die angegebene Behandlerfunktion zu registrieren.

Nun gibt es auch bei der Eventbehandlung gemeinsame Teile. Um Codeduplikationen in den Eventbehandlern zu vermeiden, empfiehlt es sich, diese nicht direkt zu registrieren, sondern einen generischen Callback, innerhalb dessen sie dann aufgerufen werden. Hier ist das die Funktion mainCallback, die hier nur ein elementares Fehlerhandling macht (wenn eine unterwartete Ausnahme austritt, wird der Text dieser Ausnahme in einem alert angezeigt. Dies kann durch eine elaborierte Funktion ersetzt werden, indem man beim Aufruf dem Optionen-Hash eine eigene Funktion mainCallback übergibt.

// ----------------------------------------------------------------------------
// Handle user events ("action"s)
// ----------------------------------------------------------------------------  
  function registerHandlers(event,handlers,opt={}) {

    setDefaultOptions()

    for (let action of Object.keys(handlers)) {
      let elements = getElementsByAction(action,opt.selector)
      if (elements.length) {
        for (let e of getElementsByAction(action,opt.selector)) {
          e.addEventListener(event,evt=>mainCallback.call(this,evt,handlers[action]))
        }  
      } else {
        console.log(`No element found for action ${action}`)
      }
    }    

    function setDefaultOptions() {
      opt.mainCallback = opt.mainCallback || mainCallback
    }

  }  
  
// CSS Selector to return elements for a given action (changeable by module consumer)
  function actionSelector(action) {
    return `[data-action="${action}"]`
  }

// Shorthand to give an array of all elements for an action
  function getElementsByAction(action,selector=actionSelector) {
    return Array.from( document.querySelectorAll(selector(action)))
  }  

// Main callback, wraps all single registered callbacks
// Rewritable by module consumer
  function mainCallback(event,callback) {
     try {
       callback.call(this,event)
     } catch (e) {
       alert(e.message)
     }
  }

Feldauswahl

Eine weitere Eigenschaft von Buttons ist - neben ihrer gleichsam essentiellen Eigenschaft, clickbar zu sein - in manchen Fällen aber gerade nicht clickbar zu sein - oder sogar völlig zu veschwinden. Die Ableitung solcher funktionaler Attribute eines UI-Controls aus dem aktuellen Zustand der Applikation ist die Feldauswahl.

Auch hier gibt es wieder einen speziellen und einen allgemeinen Teil. Um mit dem allgemeinen Teil anzufangen: eine allgemeine Funktion fieldSelection bekommt für disabled bzw. invisible je einen Hash mit der Angabe (als Boolescher Wert), für welche actions der betreffende Zustand zu setzen und für welche er zurückzusetzen ist:

// ----------------------------------------------------------------------------
// Field selection: which fields are disabled, which are invisible
// ----------------------------------------------------------------------------  
function fieldSelection(opt) {  
  evaluateFieldSelection(opt.disabled,(e,state)=>{e.disabled=state})
  evaluateFieldSelection(opt.invisible,(e,state)=>{e.style.display=state?"none":""})
}

function evaluateFieldSelection(actions,setState) {
  if (typeof actions != "object") return
  for( let action in actions) {
    for (let e of getElementsByAction(action)) {
      setState(e,actions[action])
    }
  }
}
Die Regeln selbst sind natürlich anwendungsspezifisch. In unserer Beispielapplikation hängen die Zustände der Buttons nur vom allgemeinen editMode sowie von der Information ab, ob es für irgendeines der angezeigten Property-Files eine Datenänderung (durch den Benutzer) gibt. Diese Funktion fieldSelection() wird im Anschluß am Ende jedes Ereignisbehandlers aufgerufen, der (wenigstens potentiell) die Datenänderung ermöglicht:
// -------------------------------------------------------------------
// Make parts of the UI invisible or disabled, depending on the mode
// -------------------------------------------------------------------
  function fieldSelection() {
    var dataLoss = getDataLoss()
    ui.fieldSelection({
      invisible:{
        "save":!editMode
      },
      disabled:{
        "reload":!tableContainer.querySelector("table"),
        "save":editMode && !dataLoss
      }
    })
    
  }

Tabellen

Die dataTables von Allan Jardine sind unbestritten der Mercedes unter den Tabellenpräsentationen. Sortieren (auch nach mehreren Spalten), Suchen, Filtern, fixierbare Zeilen und Spalten, Blättern, Datenquelle auf dem Client oder Server, editierbare Zellen und formatierbare Zellinhalte: es wäre schön, wenn es eine in den Browser eingebaute, über Standard-Sprachelemente ansprechbare Komponente gäbe, die all das bietet. Aber davon sind wir noch weit entfernt.[4]

Andererseits genügen oft die Standardfunktionen der HTML-<table> für eine einfache Darstellung tabellenförmiger Daten: immerhin gibt es eingebaut bereits Kopf- und Fußzeilen, über Zeilen wie auch über Spalten verbindbare Zellen, Beschriftungen, editierbare Zellen und ein komfortables, auf Tabellendaten zugeschnittenes DOM-API.

Für strukturierte Daten nehmen wir das gute alte Standardbeispiel, einen Umsatzbericht: ein Unternehmen habe in seiner Nordgruppe 15.273 Mio. $, in der Südgruppe nur 10.358 Mio. $ erwirtschaftet. Diese Daten mögen von irgendwoher kommen, z.B. durch einen Ajax-Request vom Server. Dann liegen sie in Form von JavaScript-Daten vor, z.B.

[
  ["North","15.273 M$"],
  ["South","10.358 M$"]
]
Schön wäre nun die Möglichkeit, diese Daten in ein Tabellenobjekt zu übernehmen (das dann in den HTML-DOM eingebunden wird), etwa so:
var t = new ui.Table()
  t.addHeader(["Region","Revenue"])
  t.addData([
    ["North","15.273 M$"],
    ["South","10.358 M$"]
  ])

Nehmen wir ferner an, wir haben einen Bereich (repräsentiert durch ein <div>-Element) als Behälter für eine dynamisch zu erstellende Tabelle, auf den wir im JavaScript mit einer Konstanten tableContainer zugreifen. Dann würden wir die Tabelle mit dieser appendChild().Anweisung präsentieren:

tableContainer.appendChild( t.table )
Mit einer Prise CSS für thead td, tbody td und tbody td:first-child gewürzt, erscheinen die Daten dann wie folgt:

Hier nun der JavaScript-Code für eine Klasse, die das leistet - wir verwenden die Klassen-Syntax von ES6 sowie die eingebauten DOM-API-Funktionen für Tabellen wie insertRow(), insertCell() usw. Die Klasse ist eine Art Scharnier zwischen den Rohdaten und dem Markup für deren Präsentation.

class Table {

  constructor() {
    this.table = createElement("TABLE")
    this.thead = this.table.createTHead()
    this.tbody = this.table.createTBody()
  }

// Expects an array, containing the header texts
  addHeaders(headers) {
     var tr = this.thead.insertRow()
     for (let col of headers) {
       tr.insertCell().textContent = col
     }
     return this
  }

// Expects an array of arrays, containing cell data
  addData(data) {
    for (let row of data) {          
      let tr = this.tbody.insertRow()
      for (let cell of row) {            
        tr.insertCell().textContent = cell
      }
    }
    return this 
  }
}
Mit dieser Klasse ist ein Grundstock gelegt, der sich bei Bedarf erweitern läßt. So genügte es mir schon für diese Anwendung nicht mehr, nur Werte als Tabellendaten zu übergeben: ich wollte auch pro Zelle ein Objekt mit Attributwerten übergeben können, etwa eine oder mehrere CSS-Stilklassen für das <td>-Element. Daher habe ich die Methode addData() wie folgt umgeschrieben:
  addData(data) {
    for (let row of data) {           
      let tr = this.tbody.insertRow()
      for (let cell of row){             
        let td = tr.insertCell()
        if (typeof cell == "object") {
          setAttributes(td,cell)
        } else {
          td.textContent = cell
        }
      }
    }
    return this  
  }
...
// ----------------------------------------------------------------------------
// Set attributes on a (given) element
// ----------------------------------------------------------------------------  
   function setAttributes(e,atts) {
     if (atts) {
       for (let a in atts) {
         if (a=="childNodes") {
           for (let n of atts[a]) e.appendChild(n)
         } else if (a=="textContent" || a=="innerHTML") {
           e[a] = atts[a]   
         } else if (a=="classList") {
           atts[a].forEach(c=>e.classList.add(c))        
         } else {
           e.setAttribute(a,atts[a])
         }
       }
     }
   }
Die Funktion setAttribute(), die hierbei abfiel, ist für beliebige Elemente verwendbar, nicht nur für den konkreten Fall der Tabellenzellen.

Editierbarkeit mit contenteditable

Wenn man einem HTML-Element (hier: den mit <td> beschriebenen Tabellenzellen) das Boolesche Attribut contenteditable gibt, so verwandelt sich ihr textförmiger Inhalt in ein Eingabefeld, wenn der Benutzer darauf klickt. Er kann dann Text eingeben, und standardmäßig wird er bereits in das Element übernommen, wenn der Benutzer seine Eingabe abschließt, indem er beispielsweise den Focus wechselt.

Wenn die Eingaben darüberhinaus noch im eigenen JavaScript-Code weiterverwendet werden sollen, kann man sich für das Ereignis input registrieren, das allerdings nach jeder Eingabe ausgelöst wird, also nach jedem eingegebenen Buchstaben eines Wortes. Ein Ereignis für eine vollständig abgeschlossene Eingabe gibt es nicht (also für den Zeitpunkt, wenn sich das Eingabefeld wieder schließt und das Element wieder in ein reines Textanzeigeelement verwandelt).

In unserer Anwendung geben wir im editMode allen generierten Zellen das Attribut contenteditable. Nach den Benutzereingaben wollen wir die geänderten Texte in das jeweilige Properties-Objekt übernehmen. Dazu registrieren wir uns für das input-Event auf der Ebene des tableContainer-Elements:

tableContainer.addEventListener("input",handleInput)
Nun ist der tableContainer nur ein Bereich, der potentiell mit einer Tabelle gefüllt wird und dann erst editierbare Tabellenzellen enthält. Wir kommen aber mit dieser einen Registrierung auf Ebene des umfassenden Bereichs aus, weil nicht abgefangene Events aufsteigen (bubbling up), bis sie auf einen Ereignisbehandler stoßen. Das erste, was wir daher im Eventbehandler prüfen, ob das originale Element, das das Event ausgelöst hat, wirklich eine Tabellenzelle ist:
// -------------------------------------------------------------------
// The user edited cell content
// -------------------------------------------------------------------
   function handleInput(e) {
     var cell = e.target
     if (cell.nodeName != "TD") return
     // Value before change 
     var oldValue = cell.getAttribute("data-old-value")
     // Current value = value after change
     var newValue = getCellText( cell )
     // Leave if there were no changes     
     if (newValue == oldValue) return
     // Key (first column) or value (subsequent columns) changed?
     if (cell.cellIndex == 0) {
        // Key changed
        if (newValue.match(/\S/)) {
          updateKey( cell )
        }
     } else {
        // Value changed
        var key = getCellText( cell.parentNode.firstElementChild )
        updatePropertyValue(
          cell.cellIndex-1,
          key,
          newValue)
     }    
     // Save this change
     cell.setAttribute("data-old-value",newValue)
     // Mark blank cells   
     cell.classList.toggle("empty",!/\S/.test(newValue))
     // Recompute button states
     fieldSelection()
   }
Danach müssen wir unterscheiden, ob ein Schlüssel (erste Spalte der Tabelle, also cell.cellIndex == 0) oder ein Wert (cell.cellIndex > 0) geändert wurde. Im ersten Fall müssen die in den einzelnen Property-Files angegebenen Werte dem neuen Schlüsselwert zugeordnet werden, während die alte Schlüssel/Wert-Paarung gelöscht wird. Wurde ein Wert geändert, muß dagegen diese Änderung nur in dem betroffenen Property-Objekt nachgezogen werden.

Einen Wert löschen

Das contenteditable-Attribut reicht nicht aus, um dem Benutzer auch die Möglichkeit zu bieten, einen Wert oder eine ganze Zeile von Schlüssel/Wert-Paaren zu löschen. Hierfür sehen wir im editMode ein kleines am rechten Rand der Zelle vor, das als Schaltfläche zum Löschen fungiert.

Die HTML-Struktur einer Zelle, die die Löschfunktion anbietet, ist nun

<td data-old-value="Behalten" title="Defined in row 1" contenteditable="true">
  Behalten
  <div class="delete" contenteditable="false">✖</div>
</td>

Aus Effizienzgründen ist es sinnvoll, die "Verzierung" mit der Löschfunktion erst dann einzubauen, wenn sie auch wirklich benötigt wird, d.h. beim Event mouseover. Wir registrieren also den Behandler

if (editMode) tableContainer.addEventListener("mouseover",handleOnMouseOver)
Wieder prüfen wir, wenn das Ereignis ausgelöst wird, zuerst, ob das auslösende Element wirklich eine Tabellenzelle ist:
  // In change mode, offer the "delete" icon
  function handleOnMouseOver(e) {
    var cell = e.target;
    // Only on table cell level
    if (cell.nodeName != "TD") return
    // If the value is missing anyway, "delete" makes no sense
    if (cell.classList.contains("missing")) return
    // An empty cell requires a real text node as first child
    // Otherwise, the "contentEditable" attribute won't work properly
    if (cell.classList.contains("empty")) setCellText(cell," ")
    // Not if the icon had already been created earlier
    var deleteArea = cell.querySelector(".delete")
    if (!deleteArea) {
      // First mouseover: create it
      deleteArea = document.createElement("div")
      // Mark the div as delete area
      deleteArea.className = "delete"
      // It's not editable, unlike the rest of the cell
      deleteArea.contentEditable = false
      // The icon as unicode symbol: 
      deleteArea.textContent = "✖"
      // Plug it into cell
      cell.appendChild(deleteArea)
      // Attach the "handleDelete" function
      deleteArea.addEventListener("click",handleDelete)
    }    
  }
Wurde das Ereignis wirklich in einer Zelle ausgelöst, so bauen wir das Lösch-Handle ein, falls es nicht bereits existierte (von einem früheren mouseover erzeugt), und registrieren den Behandler handleDelete zur Ausführung des Löschens.

Dieses Lösch-Handle fällt nun etwas aus dem Rahmen: es gehört zwar zu der Zelle, die wir mit contenteditable markiert haben, ist aber selbst nicht editierbar. Es muß also von der Editierbarkeit explizit ausgeschlossen werden. Auch können wir nun den in der Zelle eingegebenen Text nicht einfach mit dem Attribut textContent ansprechen, da dieses Attribut auch die Textinhalte untergeordneter Elemente liefert. Daher brauchen wir eigene Funktionen zum Lesen und Schreiben von Text in den Zellen:

// -------------------------------------------------------------------
// Read a value from a table cell
// -------------------------------------------------------------------
  function getCellText(cell) {
    // Check the first text node only
    var text = cell.firstChild && cell.firstChild.data || ""
    // If the cell is marked empty, the content is ""
    if (!text.match(/\S/) && cell.classList.contains("empty")) return ""
    return text
  }
  
// -------------------------------------------------------------------
// Set a table cell with a value
// -------------------------------------------------------------------
  function setCellText(cell,text) {
    if (cell.childNodes.length == 0 || cell.firstChild.nodeType == Node.TEXT_NODE) {
      var textNode = document.createTextNode(text)
      cell.insertBefore(textNode,cell.firstChild)
    }
    else {
      cell.firstChild.data = text
    }
  }
Was geschieht nun bei Click auf "Löschen"?
// -------------------------------------------------------------------
// Delete one or several key/value pairs
// -------------------------------------------------------------------
  function handleDelete(e) {
    if (!e.target.classList.contains("delete")) return
    var cell = e.target.parentElement
    var row = cell.parentElement
    var key = getCellText( row.firstElementChild )
    var value = getCellText( cell )
    // Does the cell belong to the column of a single properties file? 
    var index = cell.cellIndex
    if (index > 0) {
      // Delete value in a single properties file
      propList[index-1].deleteValue(key,value)
    } else {
      // Delete values of that row from all properties files 
      propList.forEach((p,i)=>p.deleteValue(key,getCellText(row.cells[i+1])))
    }    
    compare(propList)
    e.stopPropagation()
    fieldSelection()
  }
Die Information über die zu löschenden Werte werden ermittelt, danach wird die deleteValue()-Methode der entsprechenden Property-Objekte aufgerufen. Ist dies erfolgt, wird der Vergleich der Property-Objekte neu aufgerufen, und schließlich die Feldauswahl. Das weitere Aufsteigen des Click-Events in der DOM-Hierarchie muß hier unbedingt verhindert werden (mittels e.stopPropagation(), da das Click-Event sonst auch noch den Editiermodus für die betreffende Zelle öffnen würde, wenn es beim <td>-Element angekommen ist.

Die node-webkit-Anwendung

Die JavaScript-Plattform node-webkit (auch unter dem neuen Namen nwjs.io) würde es erlauben, eine Anwendung mit HTML-UI zusammen mit der nodejs-Laufzeit in eine ausführbare Datei zu packen (genauer: je Zielsystem eine, also eine für Windows, eine für Linux, eine für Mac). Diese Lösung finde ich unelegant, da der Hersteller einer Software riesige redundante Datenmengen im Weltnetz herumschleudert: wenn er effizient und ohne Redundanzen entwickelt, benötigt die Logik seiner Applikation vielleicht nur 50 KB, aber die ausführbare Datei kann locker tausendmal so groß werden. Dem steht natürlich der Vorteil gegenüber, daß der Anwender keinen separaten Installationsaufwand hat, da er node und webkit nicht separat installieren muß. Als Vorteil könnte man auch ansehen, daß node und webkit in der ausführbaren Version auf einem festen, eingefrorenen Stand sind. Es kann nicht zu einem Abbruch der Anwendung aufgrund inkompatibler Erweiterungen von node und webkit kommen.

Wie auch immer. Meine bevorzugte Version ist ein Icon nwjs auf dem Desktop, auf das ich per Drag und Drop den Ordner mit meinen Anwendungsressourcen ziehen kann, um sie zu öffnen und auszuführen. Einzig notwendig ist dafür, daß der Ordner ein package.json File mit Angaben für die nwjs-Laufzeit enthält. Wichtigste Angabe ist main, das eine URL für das Start-HTML (das index.html) enthalten muß, mit dem die Applikation beginnt. Dies ist in der Regel eine File-URL, kann aber auch eine http-URL sein. Für eine Anwendung wie diese, deren Ressourcen vollständig aus dem Web geladen werden, genügt es, daß der Ordner die Datei package.json enthält.

So sieht das package.json für den Property Editor aus:

{  
  "name": "propcmp",  
  "main": "http://ruediger-plantiko.net/property-editor/?edit",
  "node-remote": "http://ruediger-plantiko.net",
  "window": {
    "width":1800,
    "height":1200
  }
}
Man kann Angaben zur Fenstergröße, aber auch zum Resizing machen, könnte einen Fenstertitel angeben, weitere Browser-Plugins zulassen u.a.m. Es gibt eine Spezifikation für derartige Package-Files von CommonJS-Anwendungen, allerdings interpretiert nwjs nur einen Teil der dort spezifizierten Felder.

Dateiauswahl

Die Applikation ist eine reine Client-Applikation. Datenquelle ist das Dateisystem. Die Daten, die der Benutzer anzeigen oder bearbeiten will, sind .poperties-Dateien auf seiner Festplatte. Nun ist in Browser das Arbeiten mit dem Dateisystem gewissen Restriktionen unterworfen, aber die Auswahl und das Einlesen von Dateien sind möglich. Was im Browser aber nicht geht, ist das Speichern und die Verwendung von Dateinamen im JavaScript-Layer.

In HTML5 wurde eine File API eingeführt, eine Klasse für dateiartige Objekte, Spezialisierung der Klasse Blob, die beliebige, nicht änderbare Rohdaten repräsentiert. Es können Dateiobjekte mit JavaScript-generiertem Inhalt erzeugt werden, z.B. kann man Graphiken dynamisch generieren und im Browser anzeigen oder zum Download anbieten, ohne daß der Server hierfür etwas tun müßte. Dateiobjekte können auch mit der Klasse FileReader gelesen und ausgewertet werden – und natürlich an den Server hochgeladen werden.

Zur Eingabe von Dateien gibt es das Element <input type="file">. Es ist an den Dateiauswahldialog des jeweiligen Betriebssystems angeschlossen. Mehrfachauswahl ist mit dem Booleschen Attribut multiple möglich. Hat der User seine Auswahl durch Druck auf "Öffnen" bestätigt, löst das Element ein change-Event aus und stellt die ausgewählten Dateien in Form von File-Objekten in seinem DOM-Listenattribut files zur Verfügung. Diese files kann man dann in seinem Ereignisbehandler verwenden.

Leider ist man mit diesem Dateiauswahldialog sehr nah am Betriebssystem, verläßt gleichsam die reine Browser-Ebene. Die Darstellung des Dialogs und des <input type="file">-Elements lassen sich kaum beeinflussen. Das <input type="file">-Element wird wie ein Standardbutton dargestellt, die Zuweisung von Stilklassen für Rahmen, Hintergrundfarbe usw. bleibt wirkungslos.

Die einfachste Lösung für dieses Problem ist, dem Eingabeelement einen <label> zuzuordnen und das Eingabeelement selbst unsichtbar zu machen. Indem das <label>-Element mit dem for-Attribut dem (unsichtbaren) Eingabeelement zugeordnet wird, übernimmt es dessen Funktion (auch bei Click auf den Label erscheint der Auswahldialog, und nach erfolgter Auswahl löst das Eingabeelement das change-Event aus).

Mit diesem Wissen implementiert man also eine Dateiauswahlmöglichkeit im HTML-Dokument wie folgt:

   <div>
     <input type="file" multiple id="property-files">
     <label class="button" for="property-files" data-action="choose-files">
       Choose files
     </label>
     <div class="additional-info" id="selected-files">No files selected</div>
   </div>
Das Element <div class="additional-info" id="selected-files"> soll dabei die Namen der vom Benutzer ausgewählten Dateien anzeigen, es muß bei jedem change aktualisiert werden.

Das <label class="button"> ist dann per CSS so eingerichtet, daß es wie ein Button aussieht (was gar nicht so schlimm gelogen ist, da es ja auch wie ein Button funktioniert):

button, label.button {
    background-color: blue;
    color: white;
    padding: 4px 8px;
    font-weight: bold;
    font-size:11pt;
    margin: 7px 1px;
    border: solid gray 1px;
    cursor:pointer;
}
input[type=file] {
  display:none;
}
Die Registrierung des change-Behandlers erfolgt dann ganz normal über addEventListener:
     const inputFiles     = document.getElementById("property-files")
  ...
  inputFiles.addEventListener("change",handleChooseFiles)
Der Ereignisbehandler selbst liest dann die Dateien ein, stellt sie dar und aktualisiert den Statustext #selected-files:
// -------------------------------------------------------------------
// The user selected some files via button "Choose files"
// -------------------------------------------------------------------
   function handleChooseFiles(evt) {
     var files = Array.from(this.files)
     var status
     if (files.length) {
       status = files.map(f=>f.name).join(',')
       reload(files)
     }
     else {
       status = "No files selected"
     }
     document.getElementById("selected-files").textContent = status      
   }

Die Entwickler von node-webkit haben wegen dieser Unzulänglichkeiten im HTML erwogen, ein eigenes API für die Dateiauswahl zu entwickeln, was natürlich technisch möglich wäre. Letztlich haben sie zugunsten der Standardkompatibilität entschieden, die Dateiauswahl ebenfalls wie die Browser über das Element <input type="file"> zu steuern. Der obige Code verhält sich also in einer node-webkit-Anwendung identisch wie in einem Browser.

Anzumerken ist noch, daß ein File-Objekt im normalen Webmodus aus Sicherheitsgründen die Information über den Pfad der Datei verbirgt. Bekommt die Webseite aber höhere Zugriffsrechte, so enthält das File-Objekt auch ein Attribut path mit dem Dateipfad. Dies ist zum Beispiel der Fall, wenn die Seite als UI einer nwjs-Anwendung eingesetzt wird.

Das Datenmodell - die Klasse Properties

Als Modell für .properties-Dateien verwende ich eine Klasse i18n.Properties. Sie nimmt im Konstruktor einen Dateinamen und einen Array von Textzeilen entgegen, woher auch immer dieser Array kommt. Es gibt also keine Verknüpfung mit der File-Klasse, die Textzeilen könnten auch aus einer <textarea> genommen werden, in die sie der Benutzer manuell eingibt, oder per HTTP-Request von einer entfernten Quelle (z.B. als Konfigurationsdatei für eine Web-Anwendung, die beim Laden gelesen wird). Die Klasse wird darüberhinaus auch von der konkreten Codierung des Zeilenumbruchs unabhängig, die ja je Betriebssystem variieren kann.

Die Textzeilen werden nun einzeln dem Parser vorgelegt, der in einer eigenen Klasse PropertyRow implementiert ist, und durch Instanzen dieser Klasse ersetzt: das ist buchstäblich ein Array.map(), der den Text der Zeile in ein geparsedes Objekt verwandelt.

Mit Hilfe eines regulären Ausdrucks läßt sich der Parser für die .properties-Syntax sehr kompakt notieren:

  parseRow(row) {
    var o = {}
    // Parse text line into structured PropertyRow object
    // Using a regex, which represents the rules
    // - Arbitrary whitespace at the beginning allowed
    // - Followed by a 'key=value' instruction, where key must not contain '#'
    // - Or followed by a '#' and arbitrary more characters ( = comment )
    // - A line consisting only of whitespace is allowed 
    row.replace(
      /\s*(?:(?:([^#=\s]+)\s*=(.*))|#\s*(.*?)\s*$)/,
      (match,key,value,comment)=>
        Object.assign(o, {
          key:key,
          value:value,
          comment:comment
        }))
    if (Object.keys(o).length == 0) {
      o = (/\S/.test(row)) ? { error:true, input:row } : { blank: true, comment: "" }
    }
    if (o.key && !/\S/.test(o.value)) o.empty = true
    return o
  }

Im Konstruktor der Klasse PropertyRow wird das vom Parser erzeugte einfache JavaScript-Objekt in die gerade entstehende Instanz von PropertyRow injiziert:

  constructor(row) {
    if (row!==undefined) {
      Object.assign(this,this.parseRow(row))
    }
  }

Für Zeilen, die ein Schlüssel/Wert-Paar enthalten, werden Schlüssel und Wert als eigene Komponenten key und value fortgeschrieben. Kommentare, erkennbar an ihrer Einleitung mit einem Doppelkreuz #, landen in der Komponente comment. Leerzeilen werden mit dem Booleschen Attribut blank markiert. Ist nur der Wert leer, erhält das PropertyRow-Objekt das Attribut empty.

Zeilen, die nicht dem erlaubten Format entsprechen, werden als fehlerhaft markiert, bleiben aber im Array erhalten, solange ihre Entfernung durch Aufruf der Methode stripErrors() nicht ausdrücklich gefordert wird.

Das Properties-Objekt ist auch modifizierbar. Sobald erstmalig etwas geändert wurde, wird eine Kopie der Instanz erzeugt und im Attribut old gespeichert. Das virtuelle Attribut dataLoss gibt die Information zurück, ob etwas geändert wurde:

//-----------------------------------------------------------------------    
// Have data been changed
//-----------------------------------------------------------------------    
    get dataLoss() {
      return !!this.old
    }
Das Properties-Objekt kann nach den Schlüsseln sortiert werden. Dabei werden Kommentare, die einem Name/Wert-Paar vorangehen, mitsortiert. Eine Präambel aus Kommentaren, die das Dokument einleitet, bleibt auch beim Sortieren am Anfang stehen. Das Ende der Präambel muß allerdings durch eine Leerzeile erkannt werden, da sie sonst mit dem einleitenden Kommentar des ersten Name/Wert-Paars verwechselt werden kann.

Der Dateivergleich

Die Hauptfunktion der ganzen Anwendung ist der Vergleich mehrerer Property Files. Dafür wurde sie auch geschrieben: wenn Property Files für die Ablage sprachabhängiger Texte verwendet werden, ist es nützlich, ein Werkzeug zu haben, das die Texte in den verschiedenen Sprachen vergleicht. Sind die Texte zu einem Schlüsselwert in allen Sprachen gepflegt? Kommen Texte zu einem Schlüssel mehrfach vor (was eigentlich weder erwünscht noch erlaubt ist, aber natürlich passieren kann)?

Für diese Aufgabe gibt es die Funktion i18n.compareProperties(propList). Das Argument propList ist ein Array von Properties-Objekten. Das Ergebnis ist eine nach Schlüsseln sortierte Tabelle (als Array von Arrays), mit den Schlüsselwerten als erster Spalte und danach einer Spalte für jedes der übergebenen Properties-Objekte. Kommen Schlüssel mehrfach vor, so werden zu diesem Schlüssel mehrere Zeilen erzeugt - gerade soviele, um alle Vielfachheiten in allen Properties-Objekten darzustellen.

In jeder Zelle ab der zweiten Spalte wird ein JavaScript-Objekt übergeben, das im Attribut value den Wert und in title einen Kommentar (z.B. den Hinweis, in welcher Zeile der Datei dieser Eintrag gefunden wurde) enthält. Enthält der Wert nur Leerzeichen, so ist das Boolesche Attribut empty gesetzt. Fehlt zum Schlüssel dieser Zeile ein Wert, so ist das Boolesche Attribut missing gesetzt (das kann auch so sein, wenn für einen Schlüssel in einem Dokument mehrere Werte existieren, in einem anderen nur einer).

Die Übergabestruktur ist nicht HTML-spezifisch, die Funktion könnte daher auch in völlig anderen UIs verwendet werden.

Unit Tests

Um das Modul i18n mit Unit Tests abzusichern, verwende ich das Testframwework Mocha in Kombination mit der Chai Assertion Library (in seinem "klassischen" TDD flavour).

Mocha läßt sich sowohl als Kommando unter nodejs aufrufen, als auch im Web in einer Testseite. Am Anfang meines Testfiles i18n.test.js kann ich die Tatsache, ob es gerade im Desktop unter nodejs oder im Browser in der Testrunner-Seite aufgerufen wird, daran, ob die globale Variable window existiert (Browser) oder nicht existiert (Desktop). Je nach Umgebung lade ich das zu testende Modul i18n mittels nodejs, oder weiß bereits, daß es existiert (weil es in der Testseite via <script> includiert wurde).

// Setup the test frameworks, depending on environment
if (typeof window == "undefined") {  
  // My desktop environment (mocha / nodejs)
  let mut = process.env.MODULE_UNDER_TEST
  i18n = require(mut).i18n
  assert = require('chai').assert
} else {  
  // Browser (via testrunner page)
  i18n = window.i18n
  assert = chai.assert
}
Im Editor UltraEdit habe ich mir einen Menüpunkt make test konfiguriert, der genau dieses Kommando im aktuellen Verzeichnis aufruft. Die Aktion test ist im Makefile wie folgt definiert:
test:
 @H:/uedit32/tools/nodejs-test.bat H:/Documents/i18n/i18n.js
.PHONY: test
Das Batchfile nodejs-test.bat, auf das ich mich hier beziehe, enthält folgende Anweisungen:
@echo off
set NODE_PATH=C:/Program Files/nodejs/node_modules
set MODULE_UNDER_TEST=%1
"C:/Program Files/nodejs/node_modules/.bin/mocha" -u tdd
Im Ultraedit werden mir nun während des Entwickelns die Ergebnisse der Unit Tests im Ausgabefenster angezeigt:

Ich kann aber auch diese HTML-Testseite im Browser aufrufen, dort kann ich mit der Developer Toolbar den Code auch debuggen:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Property Files - Unit Tests</title>
    <link rel="stylesheet" type="text/css" href="mocha.css">
  </head>
  <body>
  <h1>Property Files - Unit Tests</h1>
  <div class="samples">   
    <div id="mocha"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.4.1/mocha.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/3.5.0/chai.min.js"></script>

    <!-- Code to test: -->
    <script src="../i18n.js"></script>
    <!-- Test files: -->
    <script>
      mocha.setup("tdd")
    </script>
    <script src="i18n.test.js"></script>
    <script>
      mocha.run();
    </script>
  </body>
</html>
Hier ist der Link auf die Testrunnerseite
http://ruediger-plantiko.net/property-editor/test/
Die Resultatseite erscheint so in einem Chrome Browser:

Fußnoten

[1] Lediglich dem JavaScript mußte ich in jener Zeit noch mit dem Framework Prototype etwas auf die Beine helfen, und auch nur, um es flüssiger und lesbarer zu machen - inzwischen sind auch solche Frameworks, auch das verbreitete jQuery, weitgehend überflüssig.
[2] Wobei XML weiterhin bleiben wird, es hat seine ganz besonderen Stärken wie Typsicherheit dank XML Schema und die gute Transformierbarkeit in andere Formate dank XSLT. Behalten wir seine Nähe zu HTML im Hinterkopf - und daß es auch in allen gängigen Browsern nativ unterstützt wird.
[3] Selbst das Sorgenkind IE/Edge ist mittlerweile zu einem tolerierbaren Erwachsenen herangewachsen, über dessen Leistungen man zwar nicht in Begeisterungsstürme verfällt, aber der immerhin seine Arbeit mehr oder weniger befriedigend macht.
[4] Soweit ich das überblicke, scheint es noch nicht einmal einen Working Draft für eine solche Komponente zu geben.

Keine Kommentare :