Skip to content

Commit 4097e73

Browse files
committed
feat(tabs): new tabs element
1 parent 9fc7f74 commit 4097e73

File tree

3 files changed

+142
-0
lines changed

3 files changed

+142
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { default as DisclosureElement } from './disclosure/disclosure';
44
export { default as MenuElement } from './menu/menu';
55
export { default as ModalElement } from './modal/modal';
66
export { default as PopupElement } from './popup/popup';
7+
export { default as TabsElement } from './tabs/tabs';
78
export { default as ToolbarElement } from './toolbar/toolbar';
89
export { default as TooltipElement } from './tooltip/tooltip';

src/tabs/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Tabs
2+
3+
**A custom element for building accessible tabbed interfaces.**
4+
5+
## Example
6+
7+
```js
8+
import { TabsElement } from 'inclusive-elements';
9+
10+
window.customElements.define('ui-tabs', TabsElement);
11+
```
12+
13+
```html
14+
<ui-tabs>
15+
<div role="tablist" aria-label="Tabs">
16+
<button type="button" role="tab">Tab 1</button>
17+
<button type="button" role="tab">Tab 2</button>
18+
<button type="button" role="tab">Tab 3</button>
19+
</div>
20+
<div role="tabpanel">Tab Panel 1</div>
21+
<div role="tabpanel" hidden>Tab Panel 2</div>
22+
<div role="tabpanel" hidden>Tab Panel 3</div>
23+
</ui-tabs>
24+
```
25+
26+
## Behavior
27+
28+
- Descendants with `role="tab"` and `role="tabpanel"` will have appropriate `id`, `aria-controls`, and `aria-labelledby` attributes generated if they are not already set.
29+
30+
- 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.
31+
32+
- 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.
33+
34+
- The `tab` with focus is automatically activated, and its corresponding `tabpanel` will become visible.
35+
36+
## Further Reading
37+
38+
- [ARIA Authoring Practices Guide: Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)

src/tabs/tabs.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
let idCounter = 0;
2+
3+
export default class TabsElement extends HTMLElement {
4+
private idPrefix = 'tabs' + ++idCounter;
5+
6+
public connectedCallback(): void {
7+
this.tablist.addEventListener('keydown', this.onKeyDown);
8+
this.tablist.addEventListener('click', this.onClick);
9+
10+
this.selectTab(0, false);
11+
12+
this.tabs.forEach((tab, i) => {
13+
const panel = this.tabpanels[i];
14+
const tabId = tab.getAttribute('id') || this.idPrefix + '_tab_' + i;
15+
const panelId =
16+
panel.getAttribute('id') || this.idPrefix + '_panel_' + i;
17+
18+
tab.setAttribute('id', tabId);
19+
tab.setAttribute('aria-controls', panelId);
20+
21+
panel.setAttribute('id', panelId);
22+
panel.setAttribute('aria-labelledby', tabId);
23+
panel.setAttribute('tabindex', '0');
24+
});
25+
}
26+
27+
public disconnectedCallback(): void {
28+
this.tablist.removeEventListener('keydown', this.onKeyDown);
29+
this.tablist.removeEventListener('click', this.onClick);
30+
}
31+
32+
private selectTab(index: number, focus = true) {
33+
if (index < 0) index += this.tabs.length;
34+
else if (index >= this.tabs.length) index -= this.tabs.length;
35+
36+
this.tabs.forEach((tab, i) => {
37+
if (i === index) {
38+
tab.setAttribute('aria-selected', 'true');
39+
tab.removeAttribute('tabindex');
40+
this.tabpanels[i].hidden = false;
41+
if (focus) tab.focus();
42+
} else {
43+
tab.setAttribute('aria-selected', 'false');
44+
tab.setAttribute('tabindex', '-1');
45+
this.tabpanels[i].hidden = true;
46+
}
47+
});
48+
}
49+
50+
private onKeyDown = (e: KeyboardEvent) => {
51+
const target = e.target as HTMLElement;
52+
const index = this.tabs.indexOf(target);
53+
const vertical =
54+
this.tablist.getAttribute('aria-orientation') === 'vertical';
55+
let captured = false;
56+
57+
switch (e.key) {
58+
case vertical ? 'ArrowUp' : 'ArrowLeft':
59+
this.selectTab(index - 1);
60+
captured = true;
61+
break;
62+
63+
case vertical ? 'ArrowDown' : 'ArrowRight':
64+
this.selectTab(index + 1);
65+
captured = true;
66+
break;
67+
68+
case 'Home':
69+
this.selectTab(0);
70+
captured = true;
71+
break;
72+
73+
case 'End':
74+
this.selectTab(this.tabs.length - 1);
75+
captured = true;
76+
}
77+
78+
if (captured) {
79+
e.stopPropagation();
80+
e.preventDefault();
81+
}
82+
};
83+
84+
private onClick = (e: MouseEvent) => {
85+
const target = e.target as HTMLElement;
86+
const tab = target.closest<HTMLElement>('[role=tab]');
87+
if (tab) {
88+
this.selectTab(this.tabs.indexOf(tab));
89+
}
90+
};
91+
92+
private get tablist(): HTMLElement {
93+
return this.querySelector('[role=tablist]')!;
94+
}
95+
96+
private get tabs(): HTMLElement[] {
97+
return Array.from(this.querySelectorAll('[role=tab]'));
98+
}
99+
100+
private get tabpanels(): HTMLElement[] {
101+
return Array.from(this.querySelectorAll('[role=tabpanel]'));
102+
}
103+
}

0 commit comments

Comments
 (0)