Skip to content

Commit

Permalink
feat(tabs): new tabs element
Browse files Browse the repository at this point in the history
  • Loading branch information
tobyzerner committed Apr 15, 2023
1 parent 9fc7f74 commit 4097e73
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
38 changes: 38 additions & 0 deletions src/tabs/README.md
Original file line number Diff line number Diff line change
@@ -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
<ui-tabs>
<div role="tablist" aria-label="Tabs">
<button type="button" role="tab">Tab 1</button>
<button type="button" role="tab">Tab 2</button>
<button type="button" role="tab">Tab 3</button>
</div>
<div role="tabpanel">Tab Panel 1</div>
<div role="tabpanel" hidden>Tab Panel 2</div>
<div role="tabpanel" hidden>Tab Panel 3</div>
</ui-tabs>
```

## 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/)
103 changes: 103 additions & 0 deletions src/tabs/tabs.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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]'));
}
}

0 comments on commit 4097e73

Please sign in to comment.