From 4097e7364e3787740d038967d99c25079f65b9ad Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 15 Apr 2023 17:49:43 +0930 Subject: [PATCH] feat(tabs): new `tabs` element --- src/index.ts | 1 + src/tabs/README.md | 38 +++++++++++++++++ src/tabs/tabs.ts | 103 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/tabs/README.md create mode 100644 src/tabs/tabs.ts diff --git a/src/index.ts b/src/index.ts index ea64c01..31dc600 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,6 @@ export { default as DisclosureElement } from './disclosure/disclosure'; export { default as MenuElement } from './menu/menu'; export { default as ModalElement } from './modal/modal'; export { default as PopupElement } from './popup/popup'; +export { default as TabsElement } from './tabs/tabs'; export { default as ToolbarElement } from './toolbar/toolbar'; export { default as TooltipElement } from './tooltip/tooltip'; diff --git a/src/tabs/README.md b/src/tabs/README.md new file mode 100644 index 0000000..5435dec --- /dev/null +++ b/src/tabs/README.md @@ -0,0 +1,38 @@ +# Tabs + +**A custom element for building accessible tabbed interfaces.** + +## Example + +```js +import { TabsElement } from 'inclusive-elements'; + +window.customElements.define('ui-tabs', TabsElement); +``` + +```html + +
+ + + +
+
Tab Panel 1
+ + +
+``` + +## Behavior + +- Descendants with `role="tab"` and `role="tabpanel"` will have appropriate `id`, `aria-controls`, and `aria-labelledby` attributes generated if they are not already set. + +- The active `tab` will have the `aria-selected="true"` attribute set. Inactive tabs will have their `tabindex` set to `-1` so that focus remains on the active tab. + +- When focus is on the active `tab`, pressing the `Left Arrow`, `Right Arrow`, `Home`, and `End` keys can be used for navigation. If the `tablist` has `aria-orientation="vertical"`, `Down Arrow` and `Up Arrow` are used instead. + +- The `tab` with focus is automatically activated, and its corresponding `tabpanel` will become visible. + +## Further Reading + +- [ARIA Authoring Practices Guide: Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) diff --git a/src/tabs/tabs.ts b/src/tabs/tabs.ts new file mode 100644 index 0000000..1443504 --- /dev/null +++ b/src/tabs/tabs.ts @@ -0,0 +1,103 @@ +let idCounter = 0; + +export default class TabsElement extends HTMLElement { + private idPrefix = 'tabs' + ++idCounter; + + public connectedCallback(): void { + this.tablist.addEventListener('keydown', this.onKeyDown); + this.tablist.addEventListener('click', this.onClick); + + this.selectTab(0, false); + + this.tabs.forEach((tab, i) => { + const panel = this.tabpanels[i]; + const tabId = tab.getAttribute('id') || this.idPrefix + '_tab_' + i; + const panelId = + panel.getAttribute('id') || this.idPrefix + '_panel_' + i; + + tab.setAttribute('id', tabId); + tab.setAttribute('aria-controls', panelId); + + panel.setAttribute('id', panelId); + panel.setAttribute('aria-labelledby', tabId); + panel.setAttribute('tabindex', '0'); + }); + } + + public disconnectedCallback(): void { + this.tablist.removeEventListener('keydown', this.onKeyDown); + this.tablist.removeEventListener('click', this.onClick); + } + + private selectTab(index: number, focus = true) { + if (index < 0) index += this.tabs.length; + else if (index >= this.tabs.length) index -= this.tabs.length; + + this.tabs.forEach((tab, i) => { + if (i === index) { + tab.setAttribute('aria-selected', 'true'); + tab.removeAttribute('tabindex'); + this.tabpanels[i].hidden = false; + if (focus) tab.focus(); + } else { + tab.setAttribute('aria-selected', 'false'); + tab.setAttribute('tabindex', '-1'); + this.tabpanels[i].hidden = true; + } + }); + } + + private onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const index = this.tabs.indexOf(target); + const vertical = + this.tablist.getAttribute('aria-orientation') === 'vertical'; + let captured = false; + + switch (e.key) { + case vertical ? 'ArrowUp' : 'ArrowLeft': + this.selectTab(index - 1); + captured = true; + break; + + case vertical ? 'ArrowDown' : 'ArrowRight': + this.selectTab(index + 1); + captured = true; + break; + + case 'Home': + this.selectTab(0); + captured = true; + break; + + case 'End': + this.selectTab(this.tabs.length - 1); + captured = true; + } + + if (captured) { + e.stopPropagation(); + e.preventDefault(); + } + }; + + private onClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const tab = target.closest('[role=tab]'); + if (tab) { + this.selectTab(this.tabs.indexOf(tab)); + } + }; + + private get tablist(): HTMLElement { + return this.querySelector('[role=tablist]')!; + } + + private get tabs(): HTMLElement[] { + return Array.from(this.querySelectorAll('[role=tab]')); + } + + private get tabpanels(): HTMLElement[] { + return Array.from(this.querySelectorAll('[role=tabpanel]')); + } +}