Résumé
<howto-tabs>
limite le contenu visible en le divisant en plusieurs panneaux. Un seul panneau est visible à la fois, tandis que tous les onglets correspondants sont toujours visibles. Pour passer d'un panneau à un autre, vous devez sélectionner l'onglet correspondant.
L'utilisateur peut modifier la sélection de l'onglet actif en cliquant ou en utilisant les touches fléchées.
Si JavaScript est désactivé, tous les panneaux sont affichés entrelacés avec les onglets respectifs. Les onglets fonctionnent désormais comme des titres.
Référence
Démo
Voir une démonstration en direct sur GitHub
Exemple d'utilisation
<style>
howto-tab {
border: 1px solid black;
padding: 20px;
}
howto-panel {
padding: 20px;
background-color: lightgray;
}
howto-tab[selected] {
background-color: bisque;
}
Si JavaScript ne s'exécute pas, l'élément ne correspondra pas à :defined
. Dans ce cas, ce style ajoute un espacement entre les onglets et le panneau précédent.
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() {
Définissez des codes de touche pour faciliter la gestion des événements de clavier.
const KEYCODE = {
DOWN: 40,
LEFT: 37,
RIGHT: 39,
UP: 38,
HOME: 36,
END: 35,
};
Pour éviter d'appeler l'analyseur avec .innerHTML
pour chaque nouvelle instance, un modèle pour le contenu du DOM de l'ombre est partagé par toutes les instances <howto-tabs>
.
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 est un élément de conteneur pour les onglets et les panneaux.
Tous les enfants de <howto-tabs>
doivent être <howto-tab>
ou <howto-tabpanel>
. Cet élément est sans état, ce qui signifie qu'aucune valeur n'est mise en cache et qu'il change donc pendant l'exécution.
class HowtoTabs extends HTMLElement {
constructor() {
super();
Les gestionnaires d'événements qui ne sont pas associés à cet élément doivent être liés s'ils ont besoin d'accéder à this
.
this._onSlotChange = this._onSlotChange.bind(this);
Pour l'amélioration progressive, le balisage doit alterner entre les onglets et les panneaux. Les éléments qui réorganisent leurs enfants ne fonctionnent généralement pas bien avec les frameworks. Le Shadow DOM est utilisé pour réorganiser les éléments à l'aide de slots.
this.attachShadow({ mode: 'open' });
Importez le modèle partagé pour créer les emplacements des onglets et des panneaux.
this.shadowRoot.appendChild(template.content.cloneNode(true));
this._tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
this._panelSlot = this.shadowRoot.querySelector('slot[name=panel]');
Cet élément doit réagir aux nouveaux enfants, car il lie sémantiquement les onglets et les panneaux à l'aide de aria-labelledby
et aria-controls
. Les nouveaux enfants seront placés automatiquement et déclencheront l'événement slotchange. MutationObserver n'est donc pas nécessaire.
this._tabSlot.addEventListener('slotchange', this._onSlotChange);
this._panelSlot.addEventListener('slotchange', this._onSlotChange);
}
connectedCallback()
regroupe les onglets et les panneaux en les réorganisant et s'assure qu'un seul onglet est actif.
connectedCallback() {
L'élément doit gérer manuellement les événements d'entrée pour permettre le basculement à l'aide des touches fléchées et des touches Accueil / Fin.
this.addEventListener('keydown', this._onKeyDown);
this.addEventListener('click', this._onClick);
if (!this.hasAttribute('role'))
this.setAttribute('role', 'tablist');
Jusqu'à récemment, les événements slotchange
ne se déclenchaient pas lorsqu'un élément était mis à niveau par l'analyseur. C'est pourquoi l'élément appelle le gestionnaire manuellement. Une fois que le nouveau comportement sera disponible dans tous les navigateurs, vous pourrez supprimer le code ci-dessous.
Promise.all([
customElements.whenDefined('howto-tab'),
customElements.whenDefined('howto-panel'),
])
.then(() => this._linkPanels());
}
disconnectedCallback()
supprime les écouteurs d'événements ajoutés par connectedCallback()
.
disconnectedCallback() {
this.removeEventListener('keydown', this._onKeyDown);
this.removeEventListener('click', this._onClick);
}
_onSlotChange()
est appelé chaque fois qu'un élément est ajouté ou supprimé de l'un des emplacements DOM ombragés.
_onSlotChange() {
this._linkPanels();
}
_linkPanels()
associe les onglets à leurs panneaux adjacents à l'aide d'aria-controls et de aria-labelledby
. De plus, la méthode garantit qu'un seul onglet est actif.
_linkPanels() {
const tabs = this._allTabs();
Attribuez à chaque panneau un attribut aria-labelledby
qui fait référence à l'onglet qui le contrôle.
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);
});
L'élément vérifie si l'un des onglets a été marqué comme sélectionné. Sinon, le premier onglet est sélectionné.
const selectedTab =
tabs.find((tab) => tab.selected) || tabs[0];
Passez ensuite à l'onglet sélectionné. _selectTab()
se charge de marquer tous les autres onglets comme désélectionnés et de masquer tous les autres panneaux.
this._selectTab(selectedTab);
}
_allPanels()
renvoie tous les panneaux du panneau d'onglets. Cette fonction peut mémoriser le résultat si les requêtes DOM deviennent un problème de performances. L'inconvénient de la mémorisation est que les onglets et les panneaux ajoutés dynamiquement ne seront pas gérés.
Il s'agit d'une méthode et non d'un getter, car un getter implique qu'il est peu coûteux à lire.
_allPanels() {
return Array.from(this.querySelectorAll('howto-panel'));
}
_allTabs()
renvoie tous les onglets du panneau d'onglets.
_allTabs() {
return Array.from(this.querySelectorAll('howto-tab'));
}
_panelForTab()
renvoie le panneau contrôlé par l'onglet donné.
_panelForTab(tab) {
const panelId = tab.getAttribute('aria-controls');
return this.querySelector(`#${panelId}`);
}
_prevTab()
renvoie l'onglet qui précède celui actuellement sélectionné, en effectuant un retour à la ligne lorsqu'il atteint le premier.
_prevTab() {
const tabs = this._allTabs();
Utilisez findIndex()
pour trouver l'indice de l'élément actuellement sélectionné et soustraire un pour obtenir l'indice de l'élément précédent.
let newIdx = tabs.findIndex((tab) => tab.selected) - 1;
Ajoutez tabs.length
pour vous assurer que l'indice est un nombre positif et que le modulo est arrondi, si nécessaire.
return tabs[(newIdx + tabs.length) % tabs.length];
}
_firstTab()
renvoie le premier onglet.
_firstTab() {
const tabs = this._allTabs();
return tabs[0];
}
_lastTab()
renvoie le dernier onglet.
_lastTab() {
const tabs = this._allTabs();
return tabs[tabs.length - 1];
}
_nextTab()
récupère l'onglet qui suit celui actuellement sélectionné, en revenant à l'onglet précédent lorsqu'il atteint le dernier onglet.
_nextTab() {
const tabs = this._allTabs();
let newIdx = tabs.findIndex((tab) => tab.selected) + 1;
return tabs[newIdx % tabs.length];
}
reset()
désélectionne tous les onglets et masque tous les panneaux.
reset() {
const tabs = this._allTabs();
const panels = this._allPanels();
tabs.forEach((tab) => tab.selected = false);
panels.forEach((panel) => panel.hidden = true);
}
_selectTab()
marque l'onglet donné comme sélectionné. De plus, il affiche le panneau correspondant à l'onglet donné.
_selectTab(newTab) {
Désélectionnez tous les onglets et masquez tous les panneaux.
this.reset();
Obtenez le panneau auquel le newTab
est associé.
const newPanel = this._panelForTab(newTab);
Si ce panneau n'existe pas, arrêtez le processus.
if (!newPanel)
throw new Error(`No panel with id ${newPanelId}`);
newTab.selected = true;
newPanel.hidden = false;
newTab.focus();
}
_onKeyDown()
gère les pressions sur les touches dans le panneau des onglets.
_onKeyDown(event) {
Si la pression sur la touche ne provenait pas d'un élément de tabulation, il s'agissait d'une pression sur la touche dans un panneau ou dans un espace vide. Rien à faire.
if (event.target.getAttribute('role') !== 'tab')
return;
Ne gérez pas les raccourcis de modification généralement utilisés par les technologies d'assistance.
if (event.altKey)
return;
Le switch-case déterminera quel onglet doit être marqué comme actif en fonction de la touche enfoncée.
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;
Toute autre pression sur une touche est ignorée et renvoyée au navigateur.
default:
return;
}
Le navigateur peut avoir des fonctionnalités natives associées aux touches fléchées, à la touche "Accueil" ou à la touche "Fin". L'élément appelle preventDefault()
pour empêcher le navigateur d'effectuer des actions.
event.preventDefault();
Sélectionnez le nouvel onglet, qui a été déterminé dans le cas de l'interrupteur.
this._selectTab(newTab);
}
_onClick()
gère les clics dans le panneau des onglets.
_onClick(event) {
Si le clic n'était pas ciblé sur un élément d'onglet lui-même, il s'agissait d'un clic dans un panneau ou sur un espace vide. Rien à faire.
if (event.target.getAttribute('role') !== 'tab')
return;
Toutefois, si l'élément était sur un onglet, sélectionnez cet onglet.
this._selectTab(event.target);
}
}
customElements.define('howto-tabs', HowtoTabs);
howtoTabCounter
compte le nombre d'instances <howto-tab>
créées. Ce nombre permet de générer de nouveaux ID uniques.
let howtoTabCounter = 0;
HowtoTab
est un onglet pour un panneau d'onglets <howto-tabs>
. <howto-tab>
doit toujours être utilisé avec role="heading"
dans le balisage afin que la sémantique reste utilisable en cas d'échec de JavaScript.
Un <howto-tab>
déclare à quel <howto-panel>
il appartient en utilisant l'ID de ce panneau comme valeur de l'attribut aria-controls.
Un <howto-tab>
génère automatiquement un ID unique si aucun n'est spécifié.
class HowtoTab extends HTMLElement {
static get observedAttributes() {
return ['selected'];
}
constructor() {
super();
}
connectedCallback() {
Si cette instruction est exécutée, JavaScript fonctionne et l'élément change de rôle pour devenir tab
.
this.setAttribute('role', 'tab');
if (!this.id)
this.id = `howto-tab-generated-${howtoTabCounter++}`;
Définissez un état initial bien défini.
this.setAttribute('aria-selected', 'false');
this.setAttribute('tabindex', -1);
this._upgradeProperty('selected');
}
Vérifiez si une propriété possède une valeur d'instance. Si c'est le cas, copiez la valeur et supprimez la propriété d'instance afin qu'elle ne masque pas le setter de propriété de classe. Enfin, transmettez la valeur au setter de la propriété de la classe afin qu'il puisse déclencher des effets secondaires. Cela permet de se prémunir contre les cas où, par exemple, un framework a ajouté l'élément à la page et défini une valeur sur l'une de ses propriétés, mais a chargé de manière paresseuse sa définition. Sans cette protection, cette propriété serait manquante dans l'élément mis à niveau, et la propriété d'instance empêcherait l'appel du setter de propriété de classe.
_upgradeProperty(prop) {
if (this.hasOwnProperty(prop)) {
let value = this[prop];
delete this[prop];
this[prop] = value;
}
}
Les propriétés et leurs attributs correspondants doivent se refléter les uns les autres. À cet effet, le setter de propriété pour selected
gère les valeurs vraies/fausses et les reflète dans l'état de l'attribut. Il est important de noter qu'aucun effet secondaire ne se produit dans le setter de propriété. Par exemple, le setter ne définit pas aria-selected
. Au lieu de cela, cette tâche est effectuée dans le attributeChangedCallback
. En règle générale, faites en sorte que les sétteurs de propriété soient très simples. Si le fait de définir une propriété ou un attribut doit entraîner un effet secondaire (comme définir un attribut ARIA correspondant), effectuez cette tâche dans attributeChangedCallback()
. Cela évite d'avoir à gérer des scénarios de réentrée d'attributs/propriétés complexes.
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
est un panneau pour un panneau d'onglets <howto-tabs>
.
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);
})();