Tabs
componentTabs over panels. Without JavaScript every panel shows; with it, one at a time, the arrows, Home and End moving between the tabs.
Preview
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>
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>
Try it
Change the props; the example is rendered again by the server.
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>
<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>
Variants
orientation
Define the visual orientation.
The month.
The list.
The month.
The list.
The month.
The list.
The month.
The list.
Installation
php bin/console ux:install tabs --kit=github.com/parler-haut-interagir-librement/ux-kit
Props
| Prop | Type | Default | Description |
|---|---|---|---|
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.
Files to copy
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>