Zusammenfassung
<howto-tabs>
– sichtbare Inhalte werden durch die Aufteilung in mehrere Bereiche begrenzt. Es ist immer nur ein Bereich sichtbar, während alle entsprechenden Tabs immer sichtbar sind. Wenn Sie von einem Bereich zum anderen wechseln möchten, muss der entsprechende Tab ausgewählt werden.
Der Nutzer kann die Auswahl des aktiven Tabs entweder durch Klicken oder mit den Pfeiltasten ändern.
Wenn JavaScript deaktiviert ist, werden alle Bereiche verschachtelt mit den entsprechenden Tabs angezeigt. Die Tabs fungieren jetzt als Überschriften.
Referenz
Demo
Nutzungsbeispiel
<style>
howto-tab {
border: 1px solid black;
padding: 20px;
}
howto-panel {
padding: 20px;
background-color: lightgray;
}
howto-tab[selected] {
background-color: bisque;
}
Wenn JavaScript nicht ausgeführt wird, entspricht das Element nicht :defined
. In diesem Fall wird durch diesen Stil der Abstand zwischen Tabs und dem vorherigen Bereich vergrößert.
howto-tabs:not(:defined), howto-tab:not(:defined), howto-panel:not(:defined) {
display: block;
}
</style>
<howto-tabs>
<howto-tab role="heading" slot="tab">Tab 1</howto-tab>
<howto-panel role="region" slot="panel">Content 1</howto-panel>
<howto-tab role="heading" slot="tab">Tab 2</howto-tab>
<howto-panel role="region" slot="panel">Content 2</howto-panel>
<howto-tab role="heading" slot="tab">Tab 3</howto-tab>
<howto-panel role="region" slot="panel">Content 3</howto-panel>
</howto-tabs>
Code
(function() {
Definieren Sie Tastencodes, um die Verarbeitung von Tastaturereignissen zu erleichtern.
const KEYCODE = {
DOWN: 40,
LEFT: 37,
RIGHT: 39,
UP: 38,
HOME: 36,
END: 35,
};
Damit der Parser nicht für jede neue Instanz mit .innerHTML
aufgerufen wird, wird eine Vorlage für den Inhalt des Shadow-DOM von allen <howto-tabs>
-Instanzen gemeinsam genutzt.
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: flex;
flex-wrap: wrap;
}
::slotted(howto-panel) {
flex-basis: 100%;
}
</style>
<slot name="tab"></slot>
<slot name="panel"></slot>
`;
„HowtoTabs“ ist ein Containerelement für Tabs und Bereiche.
Alle untergeordneten Elemente von <howto-tabs>
müssen entweder <howto-tab>
oder <howto-tabpanel>
sein. Dieses Element ist zustandslos. Das bedeutet, dass keine Werte im Cache gespeichert werden und Änderungen während der Laufzeit funktionieren.
class HowtoTabs extends HTMLElement {
constructor() {
super();
Event-Handler, die nicht an dieses Element angehängt sind, müssen gebunden werden, wenn sie Zugriff auf this
benötigen.
this._onSlotChange = this._onSlotChange.bind(this);
Beim Progressive Enhancement sollte das Markup zwischen Tabs und Bereichen wechseln. Elemente, bei denen die Reihenfolge der untergeordneten Elemente geändert wird, funktionieren in der Regel nicht gut mit Frameworks. Stattdessen wird Shadow-DOM verwendet, um die Elemente mithilfe von Slots neu anzuordnen.
this.attachShadow({ mode: 'open' });
Importieren Sie die freigegebene Vorlage, um die Slots für Tabs und Bereiche zu erstellen.
this.shadowRoot.appendChild(template.content.cloneNode(true));
this._tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
this._panelSlot = this.shadowRoot.querySelector('slot[name=panel]');
Dieses Element muss auf neue untergeordnete Elemente reagieren, da es Tabs und Bereiche semantisch über aria-labelledby
und aria-controls
verknüpft. Neue untergeordnete Elemente werden automatisch eingefügt und lösen „slotchange“ aus. Daher ist kein MutationObserver erforderlich.
this._tabSlot.addEventListener('slotchange', this._onSlotChange);
this._panelSlot.addEventListener('slotchange', this._onSlotChange);
}
connectedCallback()
gruppiert Tabs und Bereiche neu und sorgt dafür, dass genau ein Tab aktiv ist.
connectedCallback() {
Das Element muss einige manuelle Eingabeereignisse verarbeiten, damit die Auswahl mit den Pfeiltasten und Pos1 / Ende geändert werden kann.
this.addEventListener('keydown', this._onKeyDown);
this.addEventListener('click', this._onClick);
if (!this.hasAttribute('role'))
this.setAttribute('role', 'tablist');
Bis vor Kurzem wurden slotchange
-Ereignisse nicht ausgelöst, wenn ein Element vom Parser aktualisiert wurde. Aus diesem Grund ruft das Element den Handler manuell auf. Sobald das neue Verhalten in allen Browsern verfügbar ist, kann der Code unten entfernt werden.
Promise.all([
customElements.whenDefined('howto-tab'),
customElements.whenDefined('howto-panel'),
])
.then(() => this._linkPanels());
}
Mit disconnectedCallback()
werden die Event-Listener entfernt, die mit connectedCallback()
hinzugefügt wurden.
disconnectedCallback() {
this.removeEventListener('keydown', this._onKeyDown);
this.removeEventListener('click', this._onClick);
}
_onSlotChange()
wird immer dann aufgerufen, wenn einem der Shadow-DOM-Slots ein Element hinzugefügt oder daraus entfernt wird.
_onSlotChange() {
this._linkPanels();
}
Mit _linkPanels()
werden Tabs über „aria-controls“ und aria-labelledby
mit den angrenzenden Bereichen verknüpft. Außerdem wird durch die Methode sichergestellt, dass nur ein Tab aktiv ist.
_linkPanels() {
const tabs = this._allTabs();
Weisen Sie jedem Bereich ein aria-labelledby
-Attribut zu, das auf den Tab verweist, mit dem er gesteuert wird.
tabs.forEach((tab) => {
const panel = tab.nextElementSibling;
if (panel.tagName.toLowerCase() !== 'howto-panel') {
console.error(`Tab #${tab.id} is not a` +
`sibling of a <howto-panel>`);
return;
}
tab.setAttribute('aria-controls', panel.id);
panel.setAttribute('aria-labelledby', tab.id);
});
Das Element prüft, ob einer der Tabs als ausgewählt markiert wurde. Andernfalls wird jetzt der erste Tab ausgewählt.
const selectedTab =
tabs.find((tab) => tab.selected) || tabs[0];
Wechseln Sie als Nächstes zum ausgewählten Tab. _selectTab()
sorgt dafür, dass alle anderen Tabs als nicht ausgewählt markiert und alle anderen Bereiche ausgeblendet werden.
this._selectTab(selectedTab);
}
_allPanels()
gibt alle Bereiche im Tab-Bereich zurück. Diese Funktion könnte das Ergebnis speichern, wenn die DOM-Abfragen jemals zu einem Leistungsproblem werden. Der Nachteil der Speicherung ist, dass dynamisch hinzugefügte Tabs und Bereiche nicht berücksichtigt werden.
Dies ist eine Methode und kein Getter, da ein Getter impliziert, dass das Lesen kostengünstig ist.
_allPanels() {
return Array.from(this.querySelectorAll('howto-panel'));
}
_allTabs()
gibt alle Tabs im Tab-Bereich zurück.
_allTabs() {
return Array.from(this.querySelectorAll('howto-tab'));
}
_panelForTab()
gibt das Feld zurück, das vom angegebenen Tab gesteuert wird.
_panelForTab(tab) {
const panelId = tab.getAttribute('aria-controls');
return this.querySelector(`#${panelId}`);
}
_prevTab()
gibt den Tab zurück, der vor dem aktuell ausgewählten Tab steht. Wenn der erste Tab erreicht wird, wird wieder zum letzten Tab gewechselt.
_prevTab() {
const tabs = this._allTabs();
Mit findIndex()
wird der Index des aktuell ausgewählten Elements ermittelt und dann eins subtrahiert, um den Index des vorherigen Elements zu erhalten.
let newIdx = tabs.findIndex((tab) => tab.selected) - 1;
Fügen Sie tabs.length
hinzu, damit der Index eine positive Zahl ist, und verwenden Sie den Modulo-Operator, um bei Bedarf einen Überlauf zu vermeiden.
return tabs[(newIdx + tabs.length) % tabs.length];
}
_firstTab()
gibt den ersten Tab zurück.
_firstTab() {
const tabs = this._allTabs();
return tabs[0];
}
_lastTab()
gibt den letzten Tab zurück.
_lastTab() {
const tabs = this._allTabs();
return tabs[tabs.length - 1];
}
Mit _nextTab()
wird der Tab abgerufen, der nach dem aktuell ausgewählten Tab folgt. Wenn der letzte Tab erreicht wird, wird wieder zum ersten Tab gewechselt.
_nextTab() {
const tabs = this._allTabs();
let newIdx = tabs.findIndex((tab) => tab.selected) + 1;
return tabs[newIdx % tabs.length];
}
reset()
hebt die Auswahl aller Tabs auf und blendet alle Bereiche aus.
reset() {
const tabs = this._allTabs();
const panels = this._allPanels();
tabs.forEach((tab) => tab.selected = false);
panels.forEach((panel) => panel.hidden = true);
}
_selectTab()
markiert den angegebenen Tab als ausgewählt. Außerdem wird der Bereich, der dem angegebenen Tab entspricht, wieder eingeblendet.
_selectTab(newTab) {
Heben Sie die Auswahl aller Tabs auf und blenden Sie alle Bereiche aus.
this.reset();
Rufen Sie den Bereich ab, dem die newTab
zugeordnet ist.
const newPanel = this._panelForTab(newTab);
Wenn dieses Feld nicht vorhanden ist, brechen Sie den Vorgang ab.
if (!newPanel)
throw new Error(`No panel with id ${newPanelId}`);
newTab.selected = true;
newPanel.hidden = false;
newTab.focus();
}
_onKeyDown()
verarbeitet Tastendrücke im Tab-Bereich.
_onKeyDown(event) {
Wenn der Tastendruck nicht von einem Tab-Element selbst stammt, war es ein Tastendruck in einem Bereich oder auf leerem Raum. Sie müssen nichts weiter tun.
if (event.target.getAttribute('role') !== 'tab')
return;
Verarbeiten Sie keine Tastenkombinationen mit Modifikator, die in der Regel von unterstützenden Technologien verwendet werden.
if (event.altKey)
return;
Mit der Switch-Case-Anweisung wird festgelegt, welche Schaltfläche als aktiv markiert werden soll, je nachdem, welche Taste gedrückt wurde.
let newTab;
switch (event.keyCode) {
case KEYCODE.LEFT:
case KEYCODE.UP:
newTab = this._prevTab();
break;
case KEYCODE.RIGHT:
case KEYCODE.DOWN:
newTab = this._nextTab();
break;
case KEYCODE.HOME:
newTab = this._firstTab();
break;
case KEYCODE.END:
newTab = this._lastTab();
break;
Alle anderen Tastendrücke werden ignoriert und an den Browser zurückgegeben.
default:
return;
}
Der Browser hat möglicherweise einige native Funktionen, die an die Pfeiltasten, die Pos1- oder die Ende-Taste gebunden sind. Das Element ruft preventDefault()
auf, um zu verhindern, dass der Browser Aktionen ausführt.
event.preventDefault();
Wählen Sie den neuen Tab aus, der im Switch-Case festgelegt wurde.
this._selectTab(newTab);
}
_onClick()
verarbeitet Klicks im Tab-Bereich.
_onClick(event) {
Wenn der Klick nicht auf ein Tab-Element selbst erfolgte, war es ein Klick in einem Bereich oder auf eine leere Stelle. Sie müssen nichts weiter tun.
if (event.target.getAttribute('role') !== 'tab')
return;
Wenn es sich um ein Tab-Element handelte, wählen Sie diesen Tab aus.
this._selectTab(event.target);
}
}
customElements.define('howto-tabs', HowtoTabs);
howtoTabCounter
zählt die Anzahl der erstellten <howto-tab>
-Instanzen. Die Zahl wird verwendet, um neue, eindeutige IDs zu generieren.
let howtoTabCounter = 0;
HowtoTab
ist ein Tab für ein <howto-tabs>
-Tabfeld. <howto-tab>
sollte immer zusammen mit role="heading"
im Markup verwendet werden, damit die Semantik auch dann nutzbar bleibt, wenn JavaScript nicht funktioniert.
Mit einem <howto-tab>
wird deklariert, zu welchem <howto-panel>
es gehört. Dazu wird die ID des entsprechenden Bereichs als Wert für das Attribut „aria-controls“ verwendet.
Wenn keine ID angegeben ist, wird für <howto-tab>
automatisch eine eindeutige ID generiert.
class HowtoTab extends HTMLElement {
static get observedAttributes() {
return ['selected'];
}
constructor() {
super();
}
connectedCallback() {
Wenn dieser Code ausgeführt wird, funktioniert JavaScript und das Element ändert seine Rolle in tab
.
this.setAttribute('role', 'tab');
if (!this.id)
this.id = `howto-tab-generated-${howtoTabCounter++}`;
Legen Sie einen klar definierten Anfangszustand fest.
this.setAttribute('aria-selected', 'false');
this.setAttribute('tabindex', -1);
this._upgradeProperty('selected');
}
Prüfen, ob eine Eigenschaft einen Instanzwert hat. Wenn ja, kopieren Sie den Wert und löschen Sie das Instanzattribut, damit es den Setter für das Klassenattribut nicht überschattet. Übergeben Sie den Wert schließlich an den Setter der Klassenproperty, damit er alle Nebeneffekte auslösen kann. So wird verhindert, dass beispielsweise ein Framework das Element auf der Seite hinzugefügt und einen Wert für eine seiner Eigenschaften festgelegt hat, seine Definition aber verzögert geladen wird. Ohne diese Absicherung würde dem aktualisierten Element diese Eigenschaft fehlen und die Instanzeigenschaft würde verhindern, dass der Setter der Klasseneigenschaft jemals aufgerufen wird.
_upgradeProperty(prop) {
if (this.hasOwnProperty(prop)) {
let value = this[prop];
delete this[prop];
this[prop] = value;
}
}
Properties und die entsprechenden Attribute sollten sich gegenseitig widerspiegeln. Dazu verarbeitet der Setter für das Attribut selected
„truthy“- und „falsy“-Werte und gibt diese im Status des Attributs wieder. Es ist wichtig zu beachten, dass im Property-Setter keine Nebenwirkungen auftreten. Der Setter legt beispielsweise aria-selected
nicht fest. Stattdessen wird die Arbeit in der attributeChangedCallback
erledigt. Im Allgemeinen sollten Property-Setter sehr einfach sein. Wenn das Festlegen einer Property oder eines Attributs einen Nebeneffekt haben soll (z. B. das Festlegen eines entsprechenden ARIA-Attributs), sollte diese Aufgabe in attributeChangedCallback()
ausgeführt werden. So müssen Sie keine komplexen Reentry-Szenarien für Attribute/Properties verwalten.
attributeChangedCallback() {
const value = this.hasAttribute('selected');
this.setAttribute('aria-selected', value);
this.setAttribute('tabindex', value ? 0 : -1);
}
set selected(value) {
value = Boolean(value);
if (value)
this.setAttribute('selected', '');
else
this.removeAttribute('selected');
}
get selected() {
return this.hasAttribute('selected');
}
}
customElements.define('howto-tab', HowtoTab);
let howtoPanelCounter = 0;
HowtoPanel
ist ein Bereich für ein <howto-tabs>
-Tabbed-Panel.
class HowtoPanel extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.setAttribute('role', 'tabpanel');
if (!this.id)
this.id = `howto-panel-generated-${howtoPanelCounter++}`;
}
}
customElements.define('howto-panel', HowtoPanel);
})();