Tabs
Components
Tabs are made up of a set of four components:
-
Tab - A single tab.
-
TabList - A container for Tabs.
-
TabPanel - A container of content that is visible when its corresponding Tab is selected.
-
Tabs - The top-level component containing Tab, TabList, and TabPanel components.
The Tabs components only define behavior; there is no CSS or Shadow DOM used.
This allows client code to customize every aspect of the visual design based on the dynamic attribute behavior.
Limiting scope also allowed me to focus on supporting the ARIA specification for tabs.
Besides the expected ARIA attribute updates and keyboard controls, the following is the component behavior:
-
The currently active
Tab will have the selected attribute.
-
The currently inactive
TabPanels will have the hidden attribute.
-
When a
Tab is selected, Tabs will dispatch the tab-select event.
Tips for usage:
-
When building the DOM, add the
selected attribute to the active Tab, and the hidden attribute to the inactive TabPanels.
-
If the tabs are vertical instead of horizontal, add the
aria-orientation="vertical" attribute to the TabList.
This will ensure keyboard controls adapt to work as expected.
There's a few features I still need to add:
-
Support for dynamically adding and removing
Tabs and TabPanels.
I haven't encountered a use case for this, but it might be fun to add someday.
/***********************************************
Tab
***********************************************/
let tabCounter = 0;
class Tab extends HTMLElement {
static get observedAttributes() {
return ['selected'];
}
get selected() {
return this.hasAttribute('selected');
}
set selected(value) {
if (value)
this.setAttribute('selected', '');
else
this.removeAttribute('selected');
}
attributeChangedCallback() {
this.#updateAriaAttrs();
}
connectedCallback() {
this.id ||= `tab-${++tabCounter}`;
this.setAttribute('role', 'tab');
this.#updateAriaAttrs();
this.#upgradeProperty('selected');
}
#updateAriaAttrs() {
this.setAttribute('aria-selected', this.selected.toString());
this.setAttribute('tabindex', this.selected ? '0' : '-1');
}
#upgradeProperty(prop) {
if (this.hasOwnProperty(prop)) {
let value = this[prop];
delete this[prop];
this[prop] = value;
}
}
}
window.customElements.define('x-tab', Tab);
/***********************************************
TabList
***********************************************/
class TabList extends HTMLElement {
connectedCallback() {
this.setAttribute('role', 'tablist');
}
}
window.customElements.define('x-tab-list', TabList);
/***********************************************
TabPanel
***********************************************/
let panelCounter = 0;
class TabPanel extends HTMLElement {
connectedCallback() {
this.id ||= `tab-panel-${++panelCounter}`;
this.setAttribute('role', 'tabpanel');
this.setAttribute('tabindex', '0');
}
}
window.customElements.define('x-tab-panel', TabPanel);
/***********************************************
Tabs
***********************************************/
const KEYCODE = {
TAB: 9,
END: 35,
HOME: 36,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
}
class Tabs extends HTMLElement {
connectedCallback() {
this.#linkPanelsAria();
this.addEventListener('click', this.#onClick);
this.addEventListener('keydown', this.#onKeyDown);
}
get #tabs() {
return [...this.#tabList.querySelectorAll('x-tab')];
}
get #panels() {
return [...this.querySelectorAll('x-tab-panel')];
}
get #tabList() {
return this.querySelector('x-tab-list');
}
get #selectedTab() {
return this.#tabs.find(tab => tab.selected);
}
get #selectedTabPanel() {
return this.#getPanelForTab(this.#selectedTab);
}
get #orientation() {
return this.#tabList.getAttribute('aria-orientation') || 'horizontal';
}
#linkPanelsAria() {
const tabs = this.#tabs;
const panels = this.#panels;
tabs.forEach((tab, i) => {
if (i < panels.length) {
tabs[i].setAttribute('aria-controls', panels[i].id);
panels[i].setAttribute('aria-labelledby', tabs[i].id);
}
})
}
#selectTab(tab) {
if (tab.selected) return;
this.#reset();
tab.selected = true;
this.#getPanelForTab(tab).hidden = false;
tab.focus();
this.dispatchEvent(new CustomEvent('tab-select', { detail: { tab } }))
}
#selectFirstTab() {
const tabs = this.#tabs;
this.#selectTab(tabs[0]);
}
#selectPreviousTab() {
const tabs = this.#tabs;
const previousIndex = tabs.findIndex(tab => tab.selected) - 1;
const previousTab = tabs[(previousIndex + tabs.length) % tabs.length];
this.#selectTab(previousTab);
}
#selectNextTab() {
const tabs = this.#tabs;
const nextIndex = tabs.findIndex(tab => tab.selected) + 1;
const nextTab = tabs[nextIndex % tabs.length];
this.#selectTab(nextTab);
}
#selectLastTab() {
const tabs = this.#tabs;
this.#selectTab(tabs.length - 1);
}
#getPanelForTab(tab) {
const panelId = tab.getAttribute('aria-controls');
return this.querySelector(`#${panelId}`);
}
#reset() {
const tabs = this.#tabs;
tabs.forEach(tab => tab.selected = false);
const panels = this.#panels;
panels.forEach(panel => panel.hidden = true);
}
#onClick = (event) => {
if (event.target.getAttribute('role') === 'tab') {
this.#selectTab(event.target);
}
}
#onKeyDown = (event) => {
if (event.altKey) return;
let isHandled = false;
if (['tab', 'tablist'].includes(event.target.getAttribute('role'))) {
switch (event.keyCode) {
case KEYCODE.TAB:
if (!event.shiftKey) {
this.#selectedTabPanel.focus();
isHandled = true;
}
break;
case KEYCODE.HOME:
this.#selectFirstTab();
isHandled = true;
break;
case KEYCODE.LEFT:
if (this.#orientation === 'horizontal') {
this.#selectPreviousTab();
isHandled = true;
}
break;
case KEYCODE.UP:
if (this.#orientation === 'vertical') {
this.#selectPreviousTab();
isHandled = true;
}
break;
case KEYCODE.RIGHT:
if (this.#orientation === 'horizontal') {
this.#selectNextTab();
isHandled = true;
}
break;
case KEYCODE.DOWN:
if (this.#orientation === 'vertical') {
this.#selectNextTab();
isHandled = true;
} else if (this.#tabList.compareDocumentPosition(this.#selectedTabPanel) === Node.DOCUMENT_POSITION_FOLLOWING) {
// For Horizontal tabs, if the panel is after the tablist, the DOWN key should focus the panel.
// Reference: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tabpanel_role
this.#selectedTabPanel.focus();
isHandled = true;
}
break;
case KEYCODE.END:
this.#selectLastTab();
isHandled = true;
break;
}
}
if (isHandled) {
event.preventDefault();
}
}
}
window.customElements.define('x-tabs', Tabs);
Demo
The demo below shows how to leverage the dynamic attribute behavior to implement a custom UX.
Inspect the demo using your browser's dev tools to see the attribute changes in action.
<x-tabs>
<x-tab-list>
<x-tab>Tab 1</x-tab>
<x-tab selected>Tab 2</x-tab>
<x-tab>Tab 3</x-tab>
</x-tab-list>
<x-tab-panel hidden>Panel 1</x-tab-panel>
<x-tab-panel>Panel 2</x-tab-panel>
<x-tab-panel hidden>Panel 3</x-tab-panel>
</x-tabs>
x-tabs {
x-tab-list {
display: flex;
x-tab {
cursor: pointer;
padding: 12px 32px;
&[selected] {
border-bottom: 3px solid var(--primary-color);
}
}
}
x-tab-panel {
display: block;
padding: 12px;
&[hidden] {
display: none;
}
}
}
Tab 1
Tab 2
Tab 3
Panel 1
Panel 2
Panel 3
Resources
Google's Web Component tutorials were referenced when building these components.