Skip to content

Commit

Permalink
Merge pull request #77 from github/refactor-shadowdom
Browse files Browse the repository at this point in the history
Refactor shadowdom
  • Loading branch information
keithamus authored Feb 16, 2024
2 parents 04cd36e + de4120d commit 3a0377f
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 29 deletions.
50 changes: 40 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ import '@github/tab-container-element'

```html
<tab-container>
<div role="tablist">
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
<button type="button" id="tab-three" role="tab" tabindex="-1">Tab three</button>
</div>
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
<button type="button" id="tab-three" role="tab" tabindex="-1">Tab three</button>
<div role="tabpanel" aria-labelledby="tab-one">
Panel 1
</div>
Expand All @@ -35,8 +33,17 @@ import '@github/tab-container-element'

### Events

- `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.
- `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.
- `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.
- `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.

### Parts

- `::part(tablist)` is the container which wraps all tabs. This element appears in ATs as it is `role=tablist`.
- `::part(panel)` is the container housing the currently active tabpanel.
- `::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.
- `::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.
- `::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.


### When tab panel contents are controls

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

```html
<tab-container>
<div role="tablist">
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
<div role="tabpanel" aria-labelledby="tab-one" data-tab-container-no-tabstop>
<ul role="menu" aria-label="Branches">
<li tabindex="0">branch-one</li>
<li tabindex="0">branch-two</li>
</ul>
</div>
<div role="tabpanel" aria-labelledby="tab-two" data-tab-container-no-tabstop hidden>
<ul role="menu" aria-label="Commits">
<li tabindex="0">Commit One</li>
<li tabindex="0">Commit Two</li>
</ul>
</div>
</tab-container>
```

### Vertical tabs

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.

In those cases, apply `data-tab-container-no-tabstop` to the `tabpanel` element.

```html
<tab-container vertical>
<button type="button" id="tab-one" role="tab" aria-selected="true">Tab one</button>
<button type="button" id="tab-two" role="tab" tabindex="-1">Tab two</button>
<div role="tabpanel" aria-labelledby="tab-one" data-tab-container-no-tabstop>
<ul role="menu" aria-label="Branches">
<li tabindex="0">branch-one</li>
Expand Down
55 changes: 43 additions & 12 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,24 @@

<h1>Tab Container Examples</h1>

<h2>Horizontal</h2>
<h2>Horizontal (shadow tablist)</h2>

<tab-container>
<button type="button" id="tab-one" role="tab">Tab one</button>
<button type="button" id="tab-two" role="tab">Tab two</button>
<button type="button" id="tab-three" role="tab">Tab three</button>
<div role="tabpanel" aria-labelledby="tab-one">
Panel 1
</div>
<div role="tabpanel" aria-labelledby="tab-two" hidden>
Panel 2
</div>
<div role="tabpanel" aria-labelledby="tab-three" hidden>
Panel 3
</div>
</tab-container>

<h2>Horizontal (custom tablist)</h2>

<tab-container>
<div role="tablist" aria-label="Horizontal Tabs Example">
Expand All @@ -29,7 +46,25 @@ <h2>Horizontal</h2>
</div>
</tab-container>

<h2>Vertical</h2>
<h2>Vertical (shadow tablist)</h2>

<tab-container>
<button type="button" id="tab-one" role="tab">Tab one</button>
<button type="button" id="tab-two" role="tab">Tab two</button>
<button type="button" id="tab-three" role="tab">Tab three</button>
<div role="tabpanel" aria-labelledby="tab-one">
Panel 1
</div>
<div role="tabpanel" aria-labelledby="tab-two" hidden>
Panel 2
</div>
<div role="tabpanel" aria-labelledby="tab-three" hidden>
Panel 3
</div>
</tab-container>


<h2>Vertical (custom tablist)</h2>

<tab-container>
<div role="tablist" aria-label="Vertical Tabs Example" aria-orientation="vertical">
Expand All @@ -51,16 +86,12 @@ <h2>Vertical</h2>
<h2>Panel with extra buttons</h2>

<tab-container>
<div style="display: flex">
<button>Left button, not a tab!</button>
<button>2nd Left button, not a tab!</button>
<div role="tablist" aria-label="Tabs Example with extra buttons">
<button type="button" id="tab-one" role="tab">Tab one</button>
<button type="button" id="tab-two" role="tab">Tab two</button>
<button type="button" id="tab-three" role="tab">Tab three</button>
</div>
<button>Right button, not a tab!</button>
</div>
<button>Left button, not a tab!</button>
<button type="button" id="tab-one" role="tab">Tab one</button>
<button type="button" id="tab-two" role="tab">Tab two</button>
<button type="button" id="tab-three" role="tab">Tab three</button>
<button>Right button, not a tab!</button>
<button slot="before-tabs">2nd Left button, not a tab!</button>
<div role="tabpanel" aria-labelledby="tab-one">
Panel 1
</div>
Expand Down
140 changes: 134 additions & 6 deletions src/tab-container-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,90 @@ export class TabContainerElement extends HTMLElement {
}
}

static observedAttributes = ['vertical']

get #tabList() {
return this.querySelector<HTMLElement>('[role=tablist]')
const slot = this.#tabListSlot
if (this.#tabListSlot.hasAttribute('role')) {
return slot
} else {
return slot.assignedNodes()[0] as HTMLElement
}
}

get #beforeTabsSlot() {
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="before-tabs"]')!
}

get #afterTabsSlot() {
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="after-tabs"]')!
}

get #afterPanelsSlot() {
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="after-panels"]')!
}

get #tabListSlot() {
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="tablist"]')!
}

get #panelSlot() {
return this.shadowRoot!.querySelector<HTMLSlotElement>('slot[part="panel"]')!
}

get #tabs() {
if (this.#tabListSlot.matches('[role=tablist]')) {
return this.#tabListSlot.assignedNodes() as HTMLElement[]
}
return Array.from(this.#tabList?.querySelectorAll<HTMLElement>('[role="tab"]') || []).filter(
tab => tab instanceof HTMLElement && tab.closest(this.tagName) === this,
)
}

#setup = false
get activePanel() {
return this.#panelSlot.assignedNodes()[0] as HTMLElement
}

get vertical(): boolean {
return this.#tabList?.getAttribute('aria-orientation') === 'vertical'
}

set vertical(isVertical: boolean) {
const tabList = this.#tabList
if (tabList && isVertical) {
tabList.setAttribute('aria-orientation', 'vertical')
} else {
tabList.setAttribute('aria-orientation', 'horizontal')
}
}

#setupComplete = false
#internals!: ElementInternals | null
connectedCallback(): void {
this.#internals ||= this.attachInternals ? this.attachInternals() : null
const shadowRoot = this.shadowRoot || this.attachShadow({mode: 'open', slotAssignment: 'manual'})
const tabListContainer = document.createElement('div')
tabListContainer.style.display = 'flex'
const tabListSlot = document.createElement('slot')
tabListSlot.setAttribute('part', 'tablist')
const panelSlot = document.createElement('slot')
panelSlot.setAttribute('part', 'panel')
panelSlot.setAttribute('role', 'presentation')
const beforeTabSlot = document.createElement('slot')
beforeTabSlot.setAttribute('part', 'before-tabs')
const afterTabSlot = document.createElement('slot')
afterTabSlot.setAttribute('part', 'after-tabs')
tabListContainer.append(beforeTabSlot, tabListSlot, afterTabSlot)
const afterSlot = document.createElement('slot')
afterSlot.setAttribute('part', 'after-panels')
shadowRoot.replaceChildren(tabListContainer, panelSlot, afterSlot)

if (this.#internals && 'role' in this.#internals) {
this.#internals.role = 'presentation'
} else {
this.setAttribute('role', 'presentation')
}

this.addEventListener('keydown', this)
this.addEventListener('click', this)
this.selectTab(
Expand All @@ -86,7 +158,14 @@ export class TabContainerElement extends HTMLElement {
0,
),
)
this.#setup = true
this.#setupComplete = true
}

attributeChangedCallback(name: string) {
if (!this.isConnected || !this.shadowRoot) return
if (name === 'vertical') {
this.vertical = this.hasAttribute('vertical')
}
}

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

#reflectAttributeToShadow(name: string, node: Element) {
if (this.hasAttribute(name)) {
node.setAttribute(name, this.getAttribute(name)!)
this.removeAttribute(name)
}
}

selectTab(index: number): void {
if (!this.#setupComplete) {
const tabListSlot = this.#tabListSlot
const customTabList = this.querySelector('[role=tablist]')
if (customTabList && customTabList.closest(this.tagName) === this) {
tabListSlot.assign(customTabList)
} else {
tabListSlot.assign(...[...this.children].filter(e => e.matches('[role=tab]')))
tabListSlot.role = 'tablist'
tabListSlot.style.display = 'block'
}
const tabList = this.#tabList
this.#reflectAttributeToShadow('aria-description', tabList)
this.#reflectAttributeToShadow('aria-label', tabList)
if (this.vertical) {
this.#tabList.setAttribute('aria-orientation', 'vertical')
}
const beforeSlotted: Element[] = []
const afterTabSlotted: Element[] = []
const afterSlotted: Element[] = []
let autoSlotted = beforeSlotted
for (const child of this.children) {
if (child.getAttribute('role') === 'tab' || child.getAttribute('role') === 'tablist') {
autoSlotted = afterTabSlotted
continue
}
if (child.getAttribute('role') === 'tabpanel') {
autoSlotted = afterSlotted
continue
}
if (child.getAttribute('slot') === 'before-tabs') {
beforeSlotted.push(child)
} else if (child.getAttribute('slot') === 'after-tabs') {
afterTabSlotted.push(child)
} else {
autoSlotted.push(child)
}
}
this.#beforeTabsSlot.assign(...beforeSlotted)
this.#afterTabsSlot.assign(...afterTabSlotted)
this.#afterPanelsSlot.assign(...afterSlotted)
}

const tabs = this.#tabs
const panels = Array.from(this.querySelectorAll<HTMLElement>('[role="tabpanel"]')).filter(
panel => panel.closest(this.tagName) === this,
Expand All @@ -146,7 +274,7 @@ export class TabContainerElement extends HTMLElement {
const selectedTab = tabs[index]
const selectedPanel = panels[index]

if (this.#setup) {
if (this.#setupComplete) {
const cancelled = !this.dispatchEvent(
new TabContainerChangeEvent('tab-container-change', {
bubbles: true,
Expand All @@ -163,17 +291,17 @@ export class TabContainerElement extends HTMLElement {
tab.setAttribute('tabindex', '-1')
}
for (const panel of panels) {
panel.hidden = true
if (!panel.hasAttribute('tabindex') && !panel.hasAttribute('data-tab-container-no-tabstop')) {
panel.setAttribute('tabindex', '0')
}
}

selectedTab.setAttribute('aria-selected', 'true')
selectedTab.setAttribute('tabindex', '0')
this.#panelSlot.assign(selectedPanel)
selectedPanel.hidden = false

if (this.#setup) {
if (this.#setupComplete) {
selectedTab.focus()
this.dispatchEvent(
new TabContainerChangeEvent('tab-container-changed', {
Expand Down
2 changes: 1 addition & 1 deletion test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import '../src/index.ts'

describe('tab-container', function () {
const isSelected = e => e.matches('[aria-selected=true]')
const isHidden = e => e.hidden
const isHidden = e => !e.assignedSlot
let tabContainer = null
let tabs = []
let panels = []
Expand Down

0 comments on commit 3a0377f

Please sign in to comment.