Aller au contenu

Tabs

composant

Tabs over panels. Without JavaScript every panel shows; with it, one at a time, the arrows, Home and End moving between the tabs.

Aperçu

Clair

The month.
The list.
Code
<twig:Tabs defaultValue="month">
    <twig:Tabs:List aria-label="View">
        <twig:Tabs:Trigger value="month">Month</twig:Tabs:Trigger>
        <twig:Tabs:Trigger value="list">List</twig:Tabs:Trigger>
    </twig:Tabs:List>
    <twig:Tabs:Content value="month">The month.</twig:Tabs:Content>
    <twig:Tabs:Content value="list">The list.</twig:Tabs:Content>
</twig:Tabs>

Sombre

The month.
The list.
Code
<twig:Tabs defaultValue="month">
    <twig:Tabs:List aria-label="View">
        <twig:Tabs:Trigger value="month">Month</twig:Tabs:Trigger>
        <twig:Tabs:Trigger value="list">List</twig:Tabs:Trigger>
    </twig:Tabs:List>
    <twig:Tabs:Content value="month">The month.</twig:Tabs:Content>
    <twig:Tabs:Content value="list">The list.</twig:Tabs:Content>
</twig:Tabs>

Essayer

Changez les propriétés ; le serveur rend l’exemple de nouveau.

The month.
The list.
<twig:Tabs defaultValue="month">
    <twig:Tabs:List aria-label="View">
        <twig:Tabs:Trigger value="month">Month</twig:Tabs:Trigger>
        <twig:Tabs:Trigger value="list">List</twig:Tabs:Trigger>
    </twig:Tabs:List>
    <twig:Tabs:Content value="month">The month.</twig:Tabs:Content>
    <twig:Tabs:Content value="list">The list.</twig:Tabs:Content>
</twig:Tabs>

Variantes

orientation

Define the visual orientation.

The month.
The list.
The month.
The list.
orientation="horizontal"
The month.
The list.
The month.
The list.
orientation="vertical"

Installation

php bin/console ux:install tabs --kit=github.com/parler-haut-interagir-librement/ux-kit

Propriétés

PropriétéTypePar défautDescription
defaultValue string '' Define the open Tabs at initial rendering; the first one when empty.
orientation 'horizontal'|'vertical' 'horizontal' Define the visual orientation.

Accessibility

The pattern of WAI-ARIA: tablist, tab, tabpanel, the selected tab alone in the tab order, the selection following the arrows. Name the list with aria-label.

Fichiers à copier

assets/controllers/tabs_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * The tab a key moves to from `index` among `count` tabs: the next and previous ones wrapping around, the side arrows
 * mirrored when the page reads right to left, the up and down arrows only for a vertical list, Home and End the first
 * and the last; `null` for any other key.
 */
export function moved(key, index, count, { rtl = false, vertical = false } = {}) {
    const next = (index + 1) % count;
    const previous = (index - 1 + count) % count;

    switch (key) {
        case 'ArrowRight':
            return vertical ? null : rtl ? previous : next;
        case 'ArrowLeft':
            return vertical ? null : rtl ? next : previous;
        case 'ArrowDown':
            return vertical ? next : null;
        case 'ArrowUp':
            return vertical ? previous : null;
        case 'Home':
            return 0;
        case 'End':
            return count - 1;
        default:
            return null;
    }
}

/*
 * Tabs as the WAI-ARIA pattern has them: one panel shown, the selected tab alone in the tab order, the arrows, Home and
 * End moving the selection. Without JavaScript, every panel shows.
 */
export default class extends Controller {
    static targets = ['trigger', 'tab'];

    static values = { activeTab: String };

    connect() {
        if (!this.activeTabValue && this.triggerTargets.length > 0) {
            this.activeTabValue = this.triggerTargets[0].dataset.tabId;
        }

        this.#render();
    }

    open(event) {
        this.activeTabValue = event.currentTarget.dataset.tabId;
    }

    move(event) {
        const trigger = event.currentTarget;
        const index = moved(event.key, this.triggerTargets.indexOf(trigger), this.triggerTargets.length, {
            rtl: getComputedStyle(trigger).direction === 'rtl',
            vertical: trigger.parentElement?.getAttribute('aria-orientation') === 'vertical',
        });

        if (index !== null) {
            event.preventDefault();
            this.activeTabValue = this.triggerTargets[index].dataset.tabId;
            this.triggerTargets[index].focus();
        }
    }

    activeTabValueChanged() {
        this.#render();
    }

    #render() {
        for (const trigger of this.triggerTargets) {
            const active = trigger.dataset.tabId === this.activeTabValue;
            trigger.setAttribute('aria-selected', String(active));
            trigger.setAttribute('tabindex', active ? '0' : '-1');
            trigger.dataset.active = String(active);
        }

        for (const tab of this.tabTargets) {
            const active = tab.dataset.tabId === this.activeTabValue;
            tab.hidden = !active;
            tab.classList.toggle('isCurrent', active);
            tab.dataset.active = String(active);
            tab.dataset.state = active ? 'active' : 'inactive';
        }
    }
}
templates/components/Tabs.html.twig
{%- props
    ## string Define the open Tabs at initial rendering; the first one when empty.
    defaultValue = '',
    ## 'horizontal'|'vertical' Define the visual orientation.
    orientation = 'horizontal'
-%}
{%- do provide('tabs.defaultValue', defaultValue) -%}
{%- do provide('tabs.orientation', orientation) -%}
<div data-slot="tabs" data-tabs-active-tab-value="{{ defaultValue }}" data-orientation="{{ orientation }}"{{ attributes.defaults({class: 'isTabs' ~ (orientation is same as('vertical') ? ' isVertical' : ''), 'data-controller': 'tabs'}) }}>
{##- The default block. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Tabs/Content.html.twig
{%- props
    ## string Unique suffix identifier for generating Tabs internal IDs.
    value
-%}
{%- set _open = inject('tabs.defaultValue', '') is same as(value) -%}
<div id="tab-{{ value }}-description" data-slot="tabs-content" data-tabs-target="tab" data-tab-id="{{ value }}" role="tabpanel" aria-labelledby="tab-{{ value }}" tabindex="0" data-active="{{ _open ? 'true' : 'false' }}" data-state="{{ _open ? 'active' : 'inactive' }}"{{ attributes.defaults({class: 'isTabPanel' ~ (_open ? ' isCurrent' : '')}) }}>
{##- The default block. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Tabs/List.html.twig
{%- props
    ## 'default'|'line' The visual style variant.
    variant = 'default'
-%}
<div data-slot="tabs-list" role="tablist" aria-orientation="{{ inject('tabs.orientation', 'horizontal') }}" data-variant="{{ variant }}"{{ attributes.defaults({class: 'isTabList'}) }}>
{##- The default block. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Tabs/Trigger.html.twig
{%- props
    ## string Unique suffix identifier for generating Tabs internal IDs.
    value
-%}
{%- set _open = inject('tabs.defaultValue', '') is same as(value) -%}
<button type="button" id="tab-{{ value }}" data-slot="tabs-trigger" data-tabs-target="trigger" data-tab-id="{{ value }}" role="tab" aria-controls="tab-{{ value }}-description" aria-selected="{{ _open ? 'true' : 'false' }}" tabindex="{{ _open ? '0' : '-1' }}" data-active="{{ _open ? 'true' : 'false' }}"{{ attributes.defaults({class: 'isTab', 'data-action': 'click->tabs#open keydown->tabs#move'}) }}>
{##- The default block. -#}
{%- block content %}{% endblock -%}
</button>