diff --git a/README.md b/README.md index f5b20ad..09354f6 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,70 @@ In those cases, apply `data-tab-container-no-tabstop` to the `tabpanel` element. ``` +### Unmanaged slots + +`` aims to simplify complex markup away in the ShadowDOM, so that the HTML you end up writing is overall less. However sometimes it can be useful to have _full_ control over the markup. Each of the `::part` selectors are also ``s, this means you can take any part and slot it, overriding the built-in ShadowDOM. +#### Unmanaged `tablist` + +You are able to provide your own `role=tablist` and `` will accommodate. This can be useful if you need extra presentational markup in the tablist. But remember: + + - You must ensure that all child elements are `role=tab` or `role=presentational`. + - The element will still slot contents before and after this element, in order to correctly present the tablist. + +```html + +
+ + + +
+
+
+
+``` + +#### Unmanaged `tablist-tab-wrapper` + +You are able to slot the `tablist-tab-wrapper` part. This slot manages the tabs but not the before or after elements. In this way, you can put custom HTML inside the tab list. Bear in mind if you're supplying this element that: + + - You must also supply a `role=tablist` as a child. + - You must ensure that all child elements are `role=tab` or `role=presentational`. + - The element will still slot contents before and after this element, in order to correctly present the tablist. + +```html + +
+
+ + +
+
+
+
+
+``` +#### Unmanaged `tablist-wrapper` + +If you want to take full control over the entire tab region, including managing the content before and after the tabs, then you can slot the `tablist-wrapper` element. Bear in mind if you're supplying this element that: + + - `` will only manage slotting of `role=panel`. It won't manage elements before or after the tabs or panels. + - You won't be able to also slot the `tablist-tab-wrapper`. You can chose to omit this element though. + - You must also supply a `role=tablist` as a descendant. + - You must ensure that all child elements of the tablist `role=tab` or `role=presentational`. + - The element will still slot contents before and after this element, in order to correctly present the tablist. + +```html + +
+
+ + +
+
+
+
+
+``` ## Browser support diff --git a/custom-elements.json b/custom-elements.json index fe0601c..8355ce3 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -439,6 +439,12 @@ "privacy": "private", "readonly": true }, + { + "kind": "field", + "name": "#tabListWrapper", + "privacy": "private", + "readonly": true + }, { "kind": "field", "name": "#tabListTabWrapper", diff --git a/examples/index.html b/examples/index.html index e752ef6..12fc8e7 100644 --- a/examples/index.html +++ b/examples/index.html @@ -48,6 +48,27 @@

Horizontal (custom tablist)

Horizontal (custom tablist and tablist-wrapper)

+ +
+
+ + + +
+
+
+ Panel 1 +
+ + +
+ +

Horizontal (custom tablist and tablist-tab-wrapper)

+
diff --git a/src/tab-container-element.ts b/src/tab-container-element.ts index bdd9f1a..93e625a 100644 --- a/src/tab-container-element.ts +++ b/src/tab-container-element.ts @@ -95,6 +95,10 @@ export class TabContainerElement extends HTMLElement { static observedAttributes = ['vertical'] get #tabList() { + const wrapper = this.querySelector('[slot=tablist-wrapper],[slot=tablist-tab-wrapper]') + if (wrapper?.closest(this.tagName) === this) { + return wrapper.querySelector('[role=tablist]') as HTMLElement + } const slot = this.#tabListSlot if (this.#tabListTabWrapper.hasAttribute('role')) { return this.#tabListTabWrapper @@ -103,6 +107,10 @@ export class TabContainerElement extends HTMLElement { } } + get #tabListWrapper() { + return this.shadowRoot!.querySelector('slot[part="tablist-wrapper"]')! + } + get #tabListTabWrapper() { return this.shadowRoot!.querySelector('slot[part="tablist-tab-wrapper"]')! } @@ -162,9 +170,10 @@ export class TabContainerElement extends HTMLElement { connectedCallback(): void { this.#internals ||= this.attachInternals ? this.attachInternals() : null const shadowRoot = this.shadowRoot || this.attachShadow({mode: 'open', slotAssignment: 'manual'}) - const tabListContainer = document.createElement('div') + const tabListContainer = document.createElement('slot') tabListContainer.style.display = 'flex' tabListContainer.setAttribute('part', 'tablist-wrapper') + tabListContainer.setAttribute('name', 'tablist-wrapper') const tabListTabWrapper = document.createElement('slot') tabListTabWrapper.setAttribute('part', 'tablist-tab-wrapper') tabListTabWrapper.setAttribute('name', 'tablist-tab-wrapper') @@ -275,13 +284,22 @@ export class TabContainerElement extends HTMLElement { selectTab(index: number): void { if (!this.#setupComplete) { const tabListSlot = this.#tabListSlot + const tabListWrapper = this.#tabListWrapper + const tabListTabWrapper = this.#tabListTabWrapper const customTabList = this.querySelector('[role=tablist]') - const customTabListWrapper = this.querySelector('[slot=tablist-tab-wrapper]') + const customTabListWrapper = this.querySelector('[slot=tablist-wrapper]') + const customTabListTabWrapper = this.querySelector('[slot=tablist-tab-wrapper]') if (customTabListWrapper && customTabListWrapper.closest(this.tagName) === this) { if (manualSlotsSupported) { - tabListSlot.assign(customTabListWrapper) + tabListWrapper.assign(customTabListWrapper) } else { - customTabListWrapper.setAttribute('slot', 'tablist') + customTabListWrapper.setAttribute('slot', 'tablist-wrapper') + } + } else if (customTabListTabWrapper && customTabListTabWrapper.closest(this.tagName) === this) { + if (manualSlotsSupported) { + tabListTabWrapper.assign(customTabListTabWrapper) + } else { + customTabListTabWrapper.setAttribute('slot', 'tablist-tab-wrapper') } } else if (customTabList && customTabList.closest(this.tagName) === this) { if (manualSlotsSupported) { @@ -305,40 +323,43 @@ export class TabContainerElement extends HTMLElement { 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' || - child.getAttribute('slot') === 'tablist-tab-wrapper' - ) { - autoSlotted = afterTabSlotted - continue - } - if (child.getAttribute('role') === 'tabpanel') { - autoSlotted = afterSlotted - continue + const bringsOwnWrapper = this.querySelector('[slot=tablist-wrapper]')?.closest(this.tagName) === this + if (!bringsOwnWrapper) { + 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' || + child.getAttribute('slot') === 'tablist-tab-wrapper' + ) { + 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) + } } - if (child.getAttribute('slot') === 'before-tabs') { - beforeSlotted.push(child) - } else if (child.getAttribute('slot') === 'after-tabs') { - afterTabSlotted.push(child) + if (manualSlotsSupported) { + this.#beforeTabsSlot.assign(...beforeSlotted) + this.#afterTabsSlot.assign(...afterTabSlotted) + this.#afterPanelsSlot.assign(...afterSlotted) } else { - autoSlotted.push(child) + for (const el of beforeSlotted) el.setAttribute('slot', 'before-tabs') + for (const el of afterTabSlotted) el.setAttribute('slot', 'after-tabs') + for (const el of afterSlotted) el.setAttribute('slot', 'after-panels') } } - if (manualSlotsSupported) { - this.#beforeTabsSlot.assign(...beforeSlotted) - this.#afterTabsSlot.assign(...afterTabSlotted) - this.#afterPanelsSlot.assign(...afterSlotted) - } else { - for (const el of beforeSlotted) el.setAttribute('slot', 'before-tabs') - for (const el of afterTabSlotted) el.setAttribute('slot', 'after-tabs') - for (const el of afterSlotted) el.setAttribute('slot', 'after-panels') - } const defaultTab = this.defaultTabIndex const defaultIndex = defaultTab >= 0 ? defaultTab : this.selectedTabIndex index = index >= 0 ? index : Math.max(0, defaultIndex) diff --git a/test/test.js b/test/test.js index 28cb708..f5dce71 100644 --- a/test/test.js +++ b/test/test.js @@ -669,6 +669,57 @@ describe('tab-container', function () { ) }) }) + + describe('with custom tablist-wrapper', function () { + beforeEach(function () { + document.body.innerHTML = ` + +
+
+ + + +
+
+ +
+ Panel 2 +
+ +
+ ` + tabs = Array.from(document.querySelectorAll('button')) + panels = Array.from(document.querySelectorAll('[role="tabpanel"]')) + }) + + afterEach(function () { + // Check to make sure we still have accessible markup after the test finishes running. + expect(document.body).to.be.accessible() + + document.body.innerHTML = '' + }) + + it('has accessible markup', function () { + expect(document.body).to.be.accessible() + }) + + it('the second tab is still selected', function () { + assert.deepStrictEqual(tabs.map(isSelected), [false, true, false], 'Second tab is selected') + assert.deepStrictEqual(panels.map(isHidden), [true, false, true], 'Second panel is visible') + }) + + it('selects the clicked tab', function () { + tabs[0].click() + + assert.deepStrictEqual(tabs.map(isSelected), [true, false, false], 'First tab is selected') + assert.deepStrictEqual(panels.map(isHidden), [false, true, true], 'First panel is visible') + }) + }) + describe('with custom tablist-tab-wrapper', function () { beforeEach(function () { document.body.innerHTML = `