Skip to content

Commit 0779b48

Browse files
committed
Use shadowdom for tablist and panels
This refactors tab container to make extensive use of the shadowdom. This allows us to be smarter about a few things: - The `role=tablist` can be omitted, and the component will simply provide it. This makes it easier to author a tablist that is accessible because the only required markup is some `<button role=tab>` with some `<button role=panel>`. - The `role=panel` gets manually assigned which means it's far harder to show two tab-panels at once. With the current implementation it can sometimes get into states where more than the current panel is visible. With assignedSlot this is close to impossible. - Much of the required aria markup can be hidden away in the shadowdom which means consumers are less able to influence it, allowing us to keep tighter control of it.
1 parent 04cd36e commit 0779b48

3 files changed

Lines changed: 69 additions & 4 deletions

File tree

examples/index.html

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,24 @@
1010

1111
<h1>Tab Container Examples</h1>
1212

13-
<h2>Horizontal</h2>
13+
<h2>Horizontal (shadow tablist)</h2>
14+
15+
<tab-container>
16+
<button type="button" id="tab-one" role="tab">Tab one</button>
17+
<button type="button" id="tab-two" role="tab">Tab two</button>
18+
<button type="button" id="tab-three" role="tab">Tab three</button>
19+
<div role="tabpanel" aria-labelledby="tab-one">
20+
Panel 1
21+
</div>
22+
<div role="tabpanel" aria-labelledby="tab-two" hidden>
23+
Panel 2
24+
</div>
25+
<div role="tabpanel" aria-labelledby="tab-three" hidden>
26+
Panel 3
27+
</div>
28+
</tab-container>
29+
30+
<h2>Horizontal (custom tablist)</h2>
1431

1532
<tab-container>
1633
<div role="tablist" aria-label="Horizontal Tabs Example">
@@ -29,7 +46,25 @@ <h2>Horizontal</h2>
2946
</div>
3047
</tab-container>
3148

32-
<h2>Vertical</h2>
49+
<h2>Vertical (shadow tablist)</h2>
50+
51+
<tab-container>
52+
<button type="button" id="tab-one" role="tab">Tab one</button>
53+
<button type="button" id="tab-two" role="tab">Tab two</button>
54+
<button type="button" id="tab-three" role="tab">Tab three</button>
55+
<div role="tabpanel" aria-labelledby="tab-one">
56+
Panel 1
57+
</div>
58+
<div role="tabpanel" aria-labelledby="tab-two" hidden>
59+
Panel 2
60+
</div>
61+
<div role="tabpanel" aria-labelledby="tab-three" hidden>
62+
Panel 3
63+
</div>
64+
</tab-container>
65+
66+
67+
<h2>Vertical (custom tablist)</h2>
3368

3469
<tab-container>
3570
<div role="tablist" aria-label="Vertical Tabs Example" aria-orientation="vertical">

src/tab-container-element.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,35 @@ export class TabContainerElement extends HTMLElement {
7070
return this.querySelector<HTMLElement>('[role=tablist]')
7171
}
7272

73+
get #tabListSlot() {
74+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="tablist"]')!
75+
}
76+
77+
get #panelSlot() {
78+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="panel"]')!
79+
}
80+
7381
get #tabs() {
82+
if (this.#tabListSlot.matches('[role=tablist]')) {
83+
return this.#tabListSlot.assignedNodes() as HTMLElement[]
84+
}
7485
return Array.from(this.#tabList?.querySelectorAll<HTMLElement>('[role="tab"]') || []).filter(
7586
tab => tab instanceof HTMLElement && tab.closest(this.tagName) === this,
7687
)
7788
}
7889

7990
#setup = false
91+
#internals!: ElementInternals | null
8092
connectedCallback(): void {
93+
this.#internals ||= this.attachInternals ? this.attachInternals() : null
94+
const shadowRoot = this.shadowRoot || this.attachShadow({mode: 'open', slotAssignment: 'manual'})
95+
const tabListSlot = document.createElement('slot')
96+
tabListSlot.setAttribute('part', 'tablist')
97+
const panelSlot = document.createElement('slot')
98+
panelSlot.setAttribute('part', 'panel')
99+
panelSlot.setAttribute('role', 'presentation')
100+
shadowRoot.replaceChildren(tabListSlot, panelSlot)
101+
81102
this.addEventListener('keydown', this)
82103
this.addEventListener('click', this)
83104
this.selectTab(
@@ -131,6 +152,15 @@ export class TabContainerElement extends HTMLElement {
131152
}
132153

133154
selectTab(index: number): void {
155+
if (!this.#setup) {
156+
const customTabList = this.querySelector('[role=tablist]')
157+
if (customTabList && customTabList.closest(this.tagName) === this) {
158+
this.#tabListSlot.assign(customTabList)
159+
} else {
160+
this.#tabListSlot.assign(...[...this.children].filter(e => e.matches('[role=tab]')))
161+
this.#tabListSlot.role = 'tablist'
162+
}
163+
}
134164
const tabs = this.#tabs
135165
const panels = Array.from(this.querySelectorAll<HTMLElement>('[role="tabpanel"]')).filter(
136166
panel => panel.closest(this.tagName) === this,
@@ -163,14 +193,14 @@ export class TabContainerElement extends HTMLElement {
163193
tab.setAttribute('tabindex', '-1')
164194
}
165195
for (const panel of panels) {
166-
panel.hidden = true
167196
if (!panel.hasAttribute('tabindex') && !panel.hasAttribute('data-tab-container-no-tabstop')) {
168197
panel.setAttribute('tabindex', '0')
169198
}
170199
}
171200

172201
selectedTab.setAttribute('aria-selected', 'true')
173202
selectedTab.setAttribute('tabindex', '0')
203+
this.#panelSlot.assign(selectedPanel)
174204
selectedPanel.hidden = false
175205

176206
if (this.#setup) {

test/test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import '../src/index.ts'
33

44
describe('tab-container', function () {
55
const isSelected = e => e.matches('[aria-selected=true]')
6-
const isHidden = e => e.hidden
6+
const isHidden = e => !e.assignedSlot
77
let tabContainer = null
88
let tabs = []
99
let panels = []

0 commit comments

Comments
 (0)