Skip to content

Commit 3a0377f

Browse files
authored
Merge pull request #77 from github/refactor-shadowdom
Refactor shadowdom
2 parents 04cd36e + de4120d commit 3a0377f

File tree

4 files changed

+218
-29
lines changed

4 files changed

+218
-29
lines changed

README.md

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@ import '@github/tab-container-element'
1616

1717
```html
1818
<tab-container>
19-
<div role="tablist">
20-
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
21-
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
22-
<button type="button" id="tab-three" role="tab" tabindex="-1">Tab three</button>
23-
</div>
19+
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
20+
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
21+
<button type="button" id="tab-three" role="tab" tabindex="-1">Tab three</button>
2422
<div role="tabpanel" aria-labelledby="tab-one">
2523
Panel 1
2624
</div>
@@ -35,8 +33,17 @@ import '@github/tab-container-element'
3533

3634
### Events
3735

38-
- `tab-container-change` (bubbles, cancelable): fired on `<tab-container>` before a new tab is selected and visibility is updated. `event.detail.relatedTarget` is the tab panel that will be selected if the event isn't cancelled.
39-
- `tab-container-changed` (bubbles): fired on `<tab-container>` after a new tab is selected and visibility is updated. `event.detail.relatedTarget` is the newly visible tab panel.
36+
- `tab-container-change` (bubbles, cancelable): fired on `<tab-container>` before a new tab is selected and visibility is updated. `event.tab` is the tab that will be focused and `tab.panel` is the panel that will be shown if the event isn't cancelled.
37+
- `tab-container-changed` (bubbles): fired on `<tab-container>` after a new tab is selected and visibility is updated. `event.tab` is the tab that is now active (and will be focused right after this event) and `event.panel` is the newly visible tab panel.
38+
39+
### Parts
40+
41+
- `::part(tablist)` is the container which wraps all tabs. This element appears in ATs as it is `role=tablist`.
42+
- `::part(panel)` is the container housing the currently active tabpanel.
43+
- `::part(before-tabs)` is the container housing any elements that appear before the first `role=tab`. This also can be directly slotted with `slot=before-tabs`. This container lives outside the element with role=tablist to adhere to ARIA guidelines.
44+
- `::part(after-tabs)` is the container housing any elements that appear after the last `role=tab`. This also can be directly slotted with `slot=after-tabs`. This container lives outside the element with role=tablist to adhere to ARIA guidelines.
45+
- `::part(after-panels)` is the container housing any elements that appear after the last `role=tabpanel`. This can be useful if you want to add a visual treatment to the container but have content always appear visually below the active panel.
46+
4047

4148
### When tab panel contents are controls
4249

@@ -46,10 +53,33 @@ In those cases, apply `data-tab-container-no-tabstop` to the `tabpanel` element.
4653

4754
```html
4855
<tab-container>
49-
<div role="tablist">
50-
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
51-
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
56+
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
57+
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
58+
<div role="tabpanel" aria-labelledby="tab-one" data-tab-container-no-tabstop>
59+
<ul role="menu" aria-label="Branches">
60+
<li tabindex="0">branch-one</li>
61+
<li tabindex="0">branch-two</li>
62+
</ul>
5263
</div>
64+
<div role="tabpanel" aria-labelledby="tab-two" data-tab-container-no-tabstop hidden>
65+
<ul role="menu" aria-label="Commits">
66+
<li tabindex="0">Commit One</li>
67+
<li tabindex="0">Commit Two</li>
68+
</ul>
69+
</div>
70+
</tab-container>
71+
```
72+
73+
### Vertical tabs
74+
75+
If `<tab-container>` is given the `vertical` attribute it will apply the `aria-orientation=vertical` attribute to the tablist. This will present to ATs as a vertical tablist, and you can use the attribute to style the tabs accordingly.
76+
77+
In those cases, apply `data-tab-container-no-tabstop` to the `tabpanel` element.
78+
79+
```html
80+
<tab-container vertical>
81+
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
82+
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
5383
<div role="tabpanel" aria-labelledby="tab-one" data-tab-container-no-tabstop>
5484
<ul role="menu" aria-label="Branches">
5585
<li tabindex="0">branch-one</li>

examples/index.html

Lines changed: 43 additions & 12 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">
@@ -51,16 +86,12 @@ <h2>Vertical</h2>
5186
<h2>Panel with extra buttons</h2>
5287

5388
<tab-container>
54-
<div style="display: flex">
55-
<button>Left button, not a tab!</button>
56-
<button>2nd Left button, not a tab!</button>
57-
<div role="tablist" aria-label="Tabs Example with extra buttons">
58-
<button type="button" id="tab-one" role="tab">Tab one</button>
59-
<button type="button" id="tab-two" role="tab">Tab two</button>
60-
<button type="button" id="tab-three" role="tab">Tab three</button>
61-
</div>
62-
<button>Right button, not a tab!</button>
63-
</div>
89+
<button>Left button, not a tab!</button>
90+
<button type="button" id="tab-one" role="tab">Tab one</button>
91+
<button type="button" id="tab-two" role="tab">Tab two</button>
92+
<button type="button" id="tab-three" role="tab">Tab three</button>
93+
<button>Right button, not a tab!</button>
94+
<button slot="before-tabs">2nd Left button, not a tab!</button>
6495
<div role="tabpanel" aria-labelledby="tab-one">
6596
Panel 1
6697
</div>

src/tab-container-element.ts

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,90 @@ export class TabContainerElement extends HTMLElement {
6666
}
6767
}
6868

69+
static observedAttributes = ['vertical']
70+
6971
get #tabList() {
70-
return this.querySelector<HTMLElement>('[role=tablist]')
72+
const slot = this.#tabListSlot
73+
if (this.#tabListSlot.hasAttribute('role')) {
74+
return slot
75+
} else {
76+
return slot.assignedNodes()[0] as HTMLElement
77+
}
78+
}
79+
80+
get #beforeTabsSlot() {
81+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="before-tabs"]')!
82+
}
83+
84+
get #afterTabsSlot() {
85+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="after-tabs"]')!
86+
}
87+
88+
get #afterPanelsSlot() {
89+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="after-panels"]')!
90+
}
91+
92+
get #tabListSlot() {
93+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="tablist"]')!
94+
}
95+
96+
get #panelSlot() {
97+
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="panel"]')!
7198
}
7299

73100
get #tabs() {
101+
if (this.#tabListSlot.matches('[role=tablist]')) {
102+
return this.#tabListSlot.assignedNodes() as HTMLElement[]
103+
}
74104
return Array.from(this.#tabList?.querySelectorAll<HTMLElement>('[role="tab"]') || []).filter(
75105
tab => tab instanceof HTMLElement && tab.closest(this.tagName) === this,
76106
)
77107
}
78108

79-
#setup = false
109+
get activePanel() {
110+
return this.#panelSlot.assignedNodes()[0] as HTMLElement
111+
}
112+
113+
get vertical(): boolean {
114+
return this.#tabList?.getAttribute('aria-orientation') === 'vertical'
115+
}
116+
117+
set vertical(isVertical: boolean) {
118+
const tabList = this.#tabList
119+
if (tabList && isVertical) {
120+
tabList.setAttribute('aria-orientation', 'vertical')
121+
} else {
122+
tabList.setAttribute('aria-orientation', 'horizontal')
123+
}
124+
}
125+
126+
#setupComplete = false
127+
#internals!: ElementInternals | null
80128
connectedCallback(): void {
129+
this.#internals ||= this.attachInternals ? this.attachInternals() : null
130+
const shadowRoot = this.shadowRoot || this.attachShadow({mode: 'open', slotAssignment: 'manual'})
131+
const tabListContainer = document.createElement('div')
132+
tabListContainer.style.display = 'flex'
133+
const tabListSlot = document.createElement('slot')
134+
tabListSlot.setAttribute('part', 'tablist')
135+
const panelSlot = document.createElement('slot')
136+
panelSlot.setAttribute('part', 'panel')
137+
panelSlot.setAttribute('role', 'presentation')
138+
const beforeTabSlot = document.createElement('slot')
139+
beforeTabSlot.setAttribute('part', 'before-tabs')
140+
const afterTabSlot = document.createElement('slot')
141+
afterTabSlot.setAttribute('part', 'after-tabs')
142+
tabListContainer.append(beforeTabSlot, tabListSlot, afterTabSlot)
143+
const afterSlot = document.createElement('slot')
144+
afterSlot.setAttribute('part', 'after-panels')
145+
shadowRoot.replaceChildren(tabListContainer, panelSlot, afterSlot)
146+
147+
if (this.#internals && 'role' in this.#internals) {
148+
this.#internals.role = 'presentation'
149+
} else {
150+
this.setAttribute('role', 'presentation')
151+
}
152+
81153
this.addEventListener('keydown', this)
82154
this.addEventListener('click', this)
83155
this.selectTab(
@@ -86,7 +158,14 @@ export class TabContainerElement extends HTMLElement {
86158
0,
87159
),
88160
)
89-
this.#setup = true
161+
this.#setupComplete = true
162+
}
163+
164+
attributeChangedCallback(name: string) {
165+
if (!this.isConnected || !this.shadowRoot) return
166+
if (name === 'vertical') {
167+
this.vertical = this.hasAttribute('vertical')
168+
}
90169
}
91170

92171
handleEvent(event: Event) {
@@ -130,7 +209,56 @@ export class TabContainerElement extends HTMLElement {
130209
if (index >= 0) this.selectTab(index)
131210
}
132211

212+
#reflectAttributeToShadow(name: string, node: Element) {
213+
if (this.hasAttribute(name)) {
214+
node.setAttribute(name, this.getAttribute(name)!)
215+
this.removeAttribute(name)
216+
}
217+
}
218+
133219
selectTab(index: number): void {
220+
if (!this.#setupComplete) {
221+
const tabListSlot = this.#tabListSlot
222+
const customTabList = this.querySelector('[role=tablist]')
223+
if (customTabList && customTabList.closest(this.tagName) === this) {
224+
tabListSlot.assign(customTabList)
225+
} else {
226+
tabListSlot.assign(...[...this.children].filter(e => e.matches('[role=tab]')))
227+
tabListSlot.role = 'tablist'
228+
tabListSlot.style.display = 'block'
229+
}
230+
const tabList = this.#tabList
231+
this.#reflectAttributeToShadow('aria-description', tabList)
232+
this.#reflectAttributeToShadow('aria-label', tabList)
233+
if (this.vertical) {
234+
this.#tabList.setAttribute('aria-orientation', 'vertical')
235+
}
236+
const beforeSlotted: Element[] = []
237+
const afterTabSlotted: Element[] = []
238+
const afterSlotted: Element[] = []
239+
let autoSlotted = beforeSlotted
240+
for (const child of this.children) {
241+
if (child.getAttribute('role') === 'tab' || child.getAttribute('role') === 'tablist') {
242+
autoSlotted = afterTabSlotted
243+
continue
244+
}
245+
if (child.getAttribute('role') === 'tabpanel') {
246+
autoSlotted = afterSlotted
247+
continue
248+
}
249+
if (child.getAttribute('slot') === 'before-tabs') {
250+
beforeSlotted.push(child)
251+
} else if (child.getAttribute('slot') === 'after-tabs') {
252+
afterTabSlotted.push(child)
253+
} else {
254+
autoSlotted.push(child)
255+
}
256+
}
257+
this.#beforeTabsSlot.assign(...beforeSlotted)
258+
this.#afterTabsSlot.assign(...afterTabSlotted)
259+
this.#afterPanelsSlot.assign(...afterSlotted)
260+
}
261+
134262
const tabs = this.#tabs
135263
const panels = Array.from(this.querySelectorAll<HTMLElement>('[role="tabpanel"]')).filter(
136264
panel => panel.closest(this.tagName) === this,
@@ -146,7 +274,7 @@ export class TabContainerElement extends HTMLElement {
146274
const selectedTab = tabs[index]
147275
const selectedPanel = panels[index]
148276

149-
if (this.#setup) {
277+
if (this.#setupComplete) {
150278
const cancelled = !this.dispatchEvent(
151279
new TabContainerChangeEvent('tab-container-change', {
152280
bubbles: true,
@@ -163,17 +291,17 @@ export class TabContainerElement extends HTMLElement {
163291
tab.setAttribute('tabindex', '-1')
164292
}
165293
for (const panel of panels) {
166-
panel.hidden = true
167294
if (!panel.hasAttribute('tabindex') && !panel.hasAttribute('data-tab-container-no-tabstop')) {
168295
panel.setAttribute('tabindex', '0')
169296
}
170297
}
171298

172299
selectedTab.setAttribute('aria-selected', 'true')
173300
selectedTab.setAttribute('tabindex', '0')
301+
this.#panelSlot.assign(selectedPanel)
174302
selectedPanel.hidden = false
175303

176-
if (this.#setup) {
304+
if (this.#setupComplete) {
177305
selectedTab.focus()
178306
this.dispatchEvent(
179307
new TabContainerChangeEvent('tab-container-changed', {

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)