Web Components ohne JavaScript steuern
Steuern Sie Web Components nur mit HTML über benutzerdefinierte Invoker-Commands. command und commandfor verbinden Buttons mit einem Custom Element ohne JavaScript.
Custom Invoker Commands erlauben es einem Custom Element, seine Aktionen deklarativ nach außen zu geben: Der Autor der Komponente schreibt einen einzigen command-Event-Listener in JavaScript, und alle, die die Komponente einsetzen, verdrahten Buttons damit über die HTML-Attribute command und commandfor – ohne eine Zeile Skript auf Consumer-Seite.
Wer ein Custom Element ausliefert, schleppt meist einen README-Abschnitt mit: jenen Teil, der erklärt, welche Methoden aufzurufen sind oder welches data-action-Attribut man auf Buttons verteilen muss. Die Komponente funktioniert; die Verdrahtung ist die Reibung.
Dieser Artikel setzt dort an, wo unser Guide zur Invoker Commands API endet. Dort werden die eingebauten Commands für Dialoge und Popovers (show-modal, close, toggle-popover und Verwandte) sowie die Grundlagen des CommandEvent behandelt; nichts davon wird hier wiederholt. Invoker Commands sind seit dem 12. Dezember 2025 „Baseline Newly available”, ein Polyfill-Abschnitt entfällt daher ebenfalls. Stattdessen bauen wir ein auslieferbares Element, <code-viewer>, dessen öffentliche API sein Command-Set ist.
Die wichtigsten Punkte
- Namen eigener Commands müssen mit einem doppelten Bindestrich beginnen, etwa
--expand; das Präfix ist ein reservierter Namensraum, ein Custom Command kann also niemals mit einem später vom Browser ergänzten Built-in kollidieren. - Das
command-Event wird direkt auf dem durchcommandforbenannten Element ausgelöst, bubbelt nicht und überschreitet keine Shadow-Grenzen – der Listener gehört daher an die Komponente selbst. - Innerhalb des Handlers enthält
event.commandden Command-Namen undevent.sourceden Button, der ihn ausgelöst hat; dort gehörenaria-pressedundaria-expandedhin. - Eine Komponente kann aus ihrem eigenen Shadow Tree heraus nicht per
commandforadressiert werden, weil der Host dort keine id besitzt; die PropertycommandForElementakzeptiert stattdessen eine direkte Elementreferenz. - Die Spezifikation definiert für Custom Commands keine Zustandsänderungen, der Handler muss den ARIA-Zustand also selbst pflegen.
Der Autor schreibt JavaScript, der Consumer schreibt HTML
Diese Arbeitsteilung ist die eigentliche Idee hinter Custom Invoker Commands. Sie als Autor der Komponente schreiben den command-Listener einmal, innerhalb des Elements. Jeder Consumer danach steuert die Komponente aus dem Markup:
<button command="--expand" commandfor="snippet">Expand</button>
Kein Import über die Komponente selbst hinaus, keine Methodennamen zum Auswendiglernen, keine handgeschriebene Event-Delegation. Das Command-Set wird zur öffentlichen Schnittstelle des Elements – dokumentiert auf dieselbe Weise, wie command="show-modal" für <dialog> dokumentiert ist.
Warum greifen Methoden und data-*-Attribute zu kurz?
Die beiden Muster, die Komponentenautoren heute ausliefern, verschieben beide Arbeit auf den Consumer. Eine imperative Methode zwingt jeden Consumer zu JavaScript:
document.querySelector('#snippet').expand();
// plus a click listener on every button that should call it
Ein selbstgebautes data-action-Attribut hält das Markup deklarativ, verlangt aber, dass Sie das Dispatching neu implementieren: einen delegierten Click-Listener, eine Konvention zum Parsen des Attributs und Dokumentation für ein Vokabular, das nur Ihre Komponente versteht. Keiner der beiden Ansätze bringt Ihnen etwas von der Plattform. Mit command/commandfor erben Sie die Semantik eines echten Buttons, inklusive Tastaturaktivierung, sowie eine Verdrahtungskonvention, die Sie mit jedem anderen command-getriebenen Element auf der Seite teilen.
Wie definiert man einen Custom Invoker Command?
Werte eigener Commands müssen mit einem doppelten Bindestrich beginnen, und dieses Präfix ist per Definition reserviert. In den States des command-Attributs gilt jeder Wert, der mit -- beginnt, als Custom Keyword – damit bleibt kein Raum für ein Built-in in dieser Form, Ihre Commands können also mit nichts kollidieren, was der Browser später hinzufügt. Ein Wert, der weder ein eingebautes Keyword noch ---präfigiert ist, ist ungültig und löst nichts aus.
Unser <code-viewer> stellt drei Commands bereit: --expand, --toggle-wrap und --copy. Diese Liste – und nicht ein Satz von Methoden – ist, was seine Dokumentation bewirbt.
| Command | Wirkung | Auf dem Button zu aktualisierender ARIA-Zustand |
|---|---|---|
--expand | Schaltet das Attribut expanded auf dem Element um | aria-expanded |
--toggle-wrap | Schaltet das Attribut wrap auf dem Element um | aria-pressed |
--copy | Schreibt den Textinhalt des Elements in die Zwischenablage | keiner |
Wo wird das command-Event ausgelöst?
Das command-Event wird auf dem durch commandfor benannten Zielelement ausgelöst, nicht auf dem Button, und es bubbelt nicht – eine Delegation in der Bubble-Phase auf einem Vorfahren bekommt es also nicht zu sehen. Hängen Sie den Listener an das Element, das den Command empfängt; das CommandEvent-Interface gibt dem Handler zwei Properties über das Basis-Event hinaus: event.command enthält den Command-Namen, und event.source verweist zurück auf den auslösenden Button.
class CodeViewer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
this.addEventListener('command', this.#onCommand);
}
#onCommand = (event) => {
switch (event.command) {
case '--expand':
// state change goes here
break;
case '--toggle-wrap':
break;
case '--copy':
navigator.clipboard.writeText(this.textContent);
break;
}
};
}
Verzweigen Sie über event.command mit einer expliziten Fallliste und ohne großzügiges default. Jeder ---präfigierte Wert löst das Event aus, ob Sie ihn behandeln oder nicht, und es wird nichts geworfen, wenn Sie es nicht tun. Session Replays deklarativ verdrahteter Komponenten zeigen diesen Fehlerfall als toten Button: Der Klick kommt an, auf dem Bildschirm ändert sich nichts, und es erscheint kein Konsolenfehler – genau so sieht ein vertippter Command-Wert oder ein Listener am falschen Knoten aus der Nutzerperspektive aus.
Shadow DOM: den Host mit commandForElement adressieren
Eine Komponente kann aus ihrem eigenen Shadow Tree heraus nicht per commandfor adressiert werden. Das commandfor-Attribut löst nur ids auf, die im eigenen Tree des Buttons liegen, und der Host, der draußen im Light DOM sitzt, hat innerhalb seines eigenen Shadow Root keine id. Die Property commandForElement schließt diese Lücke: Sie akzeptiert statt einer id eine direkte Elementreferenz, auch über Shadow Roots hinweg. So kann ein interner Button über denselben Command-Handler laufen:
connectedCallback() {
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy';
copyBtn.setAttribute('command', '--copy');
copyBtn.commandForElement = this; // no id needed
this.shadowRoot.append(copyBtn);
}
Zwei Fakten zur Propagation sind hier wichtig. Das Event wird direkt am Ziel ausgelöst, ohne gesetztes bubbles und ohne composed, es überschreitet also niemals eine Shadow-Grenze, und event.target ist immer das Element, das es empfangen hat – kein composedPath()-Geturne erforderlich. Und event.source wird gegen den Tree des Listeners retargetiert: Für jenen internen Button sieht ein Listener auf dem Host den Host, nicht den Button. Halten Sie daher eine direkte Referenz auf interne Buttons, wenn Sie diese aktualisieren müssen.
Zustand und ARIA sind Ihre Aufgabe
Die Spezifikation definiert für eigene Command-Werte keine Zustandsänderungen; das einzige Browserverhalten ist das Auslösen des Events. Niemand setzt aria-pressed oder aria-expanded für Sie – aktualisieren Sie sie also auf event.source in demselben Zweig, der den Zustand ändert:
case '--expand': {
const expanded = this.toggleAttribute('expanded');
event.source.setAttribute('aria-expanded', expanded);
break;
}
case '--toggle-wrap': {
const wrapped = this.toggleAttribute('wrap');
event.source.setAttribute('aria-pressed', wrapped);
break;
}
Für die Light-DOM-Buttons des Consumers ist das sicher: Button und Listener teilen einen Tree, event.source ist also der tatsächliche Button.
Das fertige Element und das HTML, das es steuert
Zusammengesetzt ist die Komponente eine Klasse mit einem Listener:
class CodeViewer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
this.addEventListener('command', this.#onCommand);
}
#onCommand = (event) => {
switch (event.command) {
case '--expand': {
const expanded = this.toggleAttribute('expanded');
event.source.setAttribute('aria-expanded', expanded);
break;
}
case '--toggle-wrap': {
const wrapped = this.toggleAttribute('wrap');
event.source.setAttribute('aria-pressed', wrapped);
break;
}
case '--copy':
navigator.clipboard.writeText(this.textContent);
break;
}
};
}
customElements.define('code-viewer', CodeViewer);
Und das ist alles, was ein Consumer schreibt:
<code-viewer id="snippet">const answer = 42;</code-viewer>
<button command="--expand" commandfor="snippet" aria-expanded="false">Expand</button>
<button command="--toggle-wrap" commandfor="snippet" aria-pressed="false">Wrap</button>
<button command="--copy" commandfor="snippet">Copy</button>
Null JavaScript beim Consumer. Die Buttons können überall im Dokument stehen, in beliebiger Reihenfolge, beliebig hinzugefügt oder entfernt.
Fazit
Ein Command-Set ist eine kleinere, haltbarere öffentliche API als eine Methodenoberfläche: Es ist im Markup inspizierbar, standardmäßig per Tastatur zugänglich und über einen Namensraum so abgesichert, dass die Plattform es niemals brechen kann. Wählen Sie eine Komponente, die Sie derzeit über Methoden oder data-*-Konventionen steuern, verlegen Sie ihre Aktionen hinter ---präfigierte Commands, und lassen Sie den nächsten Consumer sie verdrahten, ohne ein Script-Tag zu öffnen.
FAQs
Was ist der Unterschied zwischen commandfor und popovertarget?
Die Attribute command und commandfor ersetzen und verallgemeinern popovertarget und popovertargetaction. Das ältere Paar zeigt, verbirgt oder schaltet lediglich Popovers um, während command und commandfor auch Dialoge mit Werten wie show-modal und close steuern und eigene Doppelbindestrich-Commands an jedes Element senden. Die neueren Attribute unterstützen alles, was die älteren konnten, neuer Code sollte daher command und commandfor bevorzugen.
Funktionieren command und commandfor auch auf anderen Elementen als button?
Nein. Die HTML-Spezifikation definiert command und commandfor ausschließlich für das button-Element, und die zugehörigen IDL-Properties, command und commandForElement, liegen auf HTMLButtonElement. Links, Inputs und andere Elemente können nicht als Invoker auftreten. Ein Custom Element, das einen nativen Button umschließt, erhält das Invoker-Verhalten ebenfalls nicht geschenkt: Der interne native Button muss die Attribute selbst tragen.
Sendet ein Button mit command sein übergeordnetes Formular ab?
Nein, und das schneidet in beide Richtungen. Ein Button, der command oder commandfor ohne explizites type trägt, ist kein Submit-Button und sendet das Formular daher nicht ab. Er führt aber auch den Command nicht aus: Ist ein Form Owner vorhanden, befindet sich type im Auto-State, und die Aktivierung kehrt zurück, bevor der Command ausgelöst wird – der Button tut also überhaupt nichts. Setzen Sie type='button' auf Invoker-Buttons innerhalb eines Formulars. Die Spezifikation kennzeichnet diese Einschränkung als Kompatibilitätsmaßnahme, die sie aufheben will.
Kann ich command-Events mit einem delegierten Listener auf einem Vorfahren behandeln?
Nur in der Capture-Phase. Das command-Event bubbelt nicht, ein gewöhnlicher delegierter Listener auf einem Vorfahren wird also nie ausgelöst. Listener in der Capture-Phase laufen auf dem Weg hinab zum Ziel, daher fängt addEventListener('command', handler, { capture: true }) auf einem Container die command-Events ab, die an seine Nachfahren im selben Tree gesendet werden. Das Event überschreitet niemals Shadow-Grenzen, die Delegation endet also an Shadow Roots.