diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index a4d2a04df2..b893258473 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -448,30 +448,30 @@ export namespace Components { } interface PostTabHeader { /** - * The name of the panel controlled by the tab header. + * The name of the tab, used to associate it with a tab panel or identify the active tab in navigation mode. */ - "panel": string; + "name": string; } interface PostTabPanel { /** - * The name of the panel, used to associate it with a tab header. + * The name of the tab that this panel is associated with. */ - "name": string; + "for": string; } interface PostTabs { /** - * The name of the panel that is initially shown. If not specified, it defaults to the panel associated with the first tab. **Changing this value after initialization has no effect.** + * The name of the tab that is initially active. If not specified, it defaults to the first tab. **Changing this value after initialization has no effect.** */ - "activePanel"?: HTMLPostTabPanelElement['name']; + "activeTab"?: string; /** * When set to true, this property allows the tabs container to span the full width of the screen, from edge to edge. * @default false */ "fullWidth": boolean; /** - * Shows the panel with the given name and selects its associated tab. Any other panel that was previously shown becomes hidden and its associated tab is unselected. + * Shows the panel with the given name and selects its associated tab. In navigation mode, only updates the active tab state. Any other panel that was previously shown becomes hidden and its associated tab is unselected. */ - "show": (panelName: string) => Promise; + "show": (tabName: string) => Promise; } interface PostTogglebutton { /** @@ -1297,28 +1297,28 @@ declare namespace LocalJSX { } interface PostTabHeader { /** - * The name of the panel controlled by the tab header. + * The name of the tab, used to associate it with a tab panel or identify the active tab in navigation mode. */ - "panel": string; + "name": string; } interface PostTabPanel { /** - * The name of the panel, used to associate it with a tab header. + * The name of the tab that this panel is associated with. */ - "name": string; + "for": string; } interface PostTabs { /** - * The name of the panel that is initially shown. If not specified, it defaults to the panel associated with the first tab. **Changing this value after initialization has no effect.** + * The name of the tab that is initially active. If not specified, it defaults to the first tab. **Changing this value after initialization has no effect.** */ - "activePanel"?: HTMLPostTabPanelElement['name']; + "activeTab"?: string; /** * When set to true, this property allows the tabs container to span the full width of the screen, from edge to edge. * @default false */ "fullWidth"?: boolean; /** - * An event emitted after the active tab changes, when the fade in transition of its associated panel is finished. The payload is the name of the newly shown panel. + * An event emitted after the active tab changes, when the fade in transition of its associated panel is finished. The payload is the name of the newly active tab. */ "onPostChange"?: (event: PostTabsCustomEvent) => void; } diff --git a/packages/components/src/components/post-tab-header/post-tab-header.tsx b/packages/components/src/components/post-tab-header/post-tab-header.tsx index 7aa08e7689..ca779698c5 100644 --- a/packages/components/src/components/post-tab-header/post-tab-header.tsx +++ b/packages/components/src/components/post-tab-header/post-tab-header.tsx @@ -4,7 +4,7 @@ import { checkRequiredAndType } from '@/utils'; import { nanoid } from 'nanoid'; /** - * @slot default - Slot for the content of the tab header. + * @slot default - Slot for the content of the tab header. Can contain text or an element for navigation mode. */ @Component({ @@ -16,29 +16,45 @@ export class PostTabHeader { @Element() host: HTMLPostTabHeaderElement; @State() tabId: string; + @State() isNavigationMode = false; /** - * The name of the panel controlled by the tab header. + * The name of the tab, used to associate it with a tab panel or identify the active tab in navigation mode. */ - @Prop({ reflect: true }) readonly panel!: string; + @Prop({ reflect: true }) readonly name!: string; - @Watch('panel') - validateFor() { - checkRequiredAndType(this, 'panel', 'string'); + @Watch('name') + validateName() { + checkRequiredAndType(this, 'name', 'string'); } componentWillLoad() { this.tabId = `tab-${this.host.id || nanoid(6)}`; + this.checkNavigationMode(); + } + + componentDidLoad() { + // Re-check navigation mode after content is loaded + this.checkNavigationMode(); + } + + private checkNavigationMode() { + const hasAnchor = this.host.querySelector('a') !== null; + this.isNavigationMode = hasAnchor; } render() { + const role = this.isNavigationMode ? undefined : 'tab'; + const ariaSelected = this.isNavigationMode ? undefined : 'false'; + const tabindex = this.isNavigationMode ? undefined : '-1'; + return ( diff --git a/packages/components/src/components/post-tab-header/readme.md b/packages/components/src/components/post-tab-header/readme.md index 6d09529475..1b7b29298f 100644 --- a/packages/components/src/components/post-tab-header/readme.md +++ b/packages/components/src/components/post-tab-header/readme.md @@ -7,16 +7,16 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| -------------------- | --------- | --------------------------------------------------- | -------- | ----------- | -| `panel` _(required)_ | `panel` | The name of the panel controlled by the tab header. | `string` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ------------------- | --------- | --------------------------------------------------------------------------------------------------------- | -------- | ----------- | +| `name` _(required)_ | `name` | The name of the tab, used to associate it with a tab panel or identify the active tab in navigation mode. | `string` | `undefined` | ## Slots -| Slot | Description | -| ----------- | --------------------------------------- | -| `"default"` | Slot for the content of the tab header. | +| Slot | Description | +| ----------- | ----------------------------------------------------------------------------------------------- | +| `"default"` | Slot for the content of the tab header. Can contain text or an element for navigation mode. | ---------------------------------------------- diff --git a/packages/components/src/components/post-tab-panel/post-tab-panel.tsx b/packages/components/src/components/post-tab-panel/post-tab-panel.tsx index 748507d851..892804f206 100644 --- a/packages/components/src/components/post-tab-panel/post-tab-panel.tsx +++ b/packages/components/src/components/post-tab-panel/post-tab-panel.tsx @@ -18,16 +18,16 @@ export class PostTabPanel { @State() panelId: string; /** - * The name of the panel, used to associate it with a tab header. + * The name of the tab that this panel is associated with. */ - @Prop({ reflect: true }) readonly name!: string; + @Prop({ reflect: true }) readonly for!: string; - @Watch('name') - validateName() { - checkRequiredAndType(this, 'name', 'string'); + @Watch('for') + validateFor() { + checkRequiredAndType(this, 'for', 'string'); } componentWillLoad() { - this.validateName(); + this.validateFor(); // get the id set on the host element or use a random id by default this.panelId = `panel-${this.host.id || nanoid(6)}`; } diff --git a/packages/components/src/components/post-tab-panel/readme.md b/packages/components/src/components/post-tab-panel/readme.md index 7655490f14..3df43b500a 100644 --- a/packages/components/src/components/post-tab-panel/readme.md +++ b/packages/components/src/components/post-tab-panel/readme.md @@ -7,9 +7,9 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------- | --------- | -------------------------------------------------------------- | -------- | ----------- | -| `name` _(required)_ | `name` | The name of the panel, used to associate it with a tab header. | `string` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ------------------ | --------- | ------------------------------------------------------- | -------- | ----------- | +| `for` _(required)_ | `for` | The name of the tab that this panel is associated with. | `string` | `undefined` | ## Slots diff --git a/packages/components/src/components/post-tabs/post-tabs.tsx b/packages/components/src/components/post-tabs/post-tabs.tsx index 1ccd9718d9..8a5858131b 100644 --- a/packages/components/src/components/post-tabs/post-tabs.tsx +++ b/packages/components/src/components/post-tabs/post-tabs.tsx @@ -16,10 +16,11 @@ import { componentOnReady } from '@/utils'; shadow: true, }) export class PostTabs { - private activeTab: HTMLPostTabHeaderElement; + private activeTabElement: HTMLPostTabHeaderElement; private showing: Animation; private hiding: Animation; private isLoaded = false; + private mode: 'panels' | 'navigation' = 'panels'; private get tabs(): HTMLPostTabHeaderElement[] { return Array.from( @@ -27,15 +28,21 @@ export class PostTabs { ).filter(tab => tab.closest('post-tabs') === this.host); } + private get panels(): HTMLPostTabPanelElement[] { + return Array.from( + this.host.querySelectorAll('post-tab-panel'), + ).filter(panel => panel.closest('post-tabs') === this.host); + } + @Element() host: HTMLPostTabsElement; /** - * The name of the panel that is initially shown. - * If not specified, it defaults to the panel associated with the first tab. + * The name of the tab that is initially active. + * If not specified, it defaults to the first tab. * * **Changing this value after initialization has no effect.** */ - @Prop() readonly activePanel?: HTMLPostTabPanelElement['name']; + @Prop() readonly activeTab?: HTMLPostTabHeaderElement['name']; /** * When set to true, this property allows the tabs container to span the @@ -45,34 +52,62 @@ export class PostTabs { /** * An event emitted after the active tab changes, when the fade in transition of its associated panel is finished. - * The payload is the name of the newly shown panel. + * The payload is the name of the newly active tab. */ @Event() postChange: EventEmitter; componentDidLoad() { this.moveMisplacedTabs(); + this.detectMode(); this.enableTabs(); - const initiallyActivePanel = this.activePanel || this.tabs[0]?.panel; - void this.show(initiallyActivePanel); + const initiallyActiveTab = this.activeTab || this.tabs[0]?.name; + void this.show(initiallyActiveTab); this.isLoaded = true; } + private detectMode() { + const tabsWithAnchors = this.tabs.filter(tab => tab.querySelector('a')); + const tabsWithoutAnchors = this.tabs.filter(tab => !tab.querySelector('a')); + const hasPanels = this.panels.length > 0; + + // Check for mixed mode (error case) + if (tabsWithAnchors.length > 0 && (tabsWithoutAnchors.length > 0 || hasPanels)) { + console.error( + 'post-tabs: Mixed mode detected. Cannot mix tabs with anchors (navigation mode) and tabs with panels (panels mode). Please use either all anchors or all panels.', + ); + return; + } + + // Determine mode + if (tabsWithAnchors.length > 0) { + this.mode = 'navigation'; + } else { + this.mode = 'panels'; + } + } + /** * Shows the panel with the given name and selects its associated tab. + * In navigation mode, only updates the active tab state. * Any other panel that was previously shown becomes hidden and its associated tab is unselected. */ @Method() - async show(panelName: string) { + async show(tabName: string) { + if (this.mode === 'navigation') { + this.setActiveTab(tabName); + return; + } + // do nothing if the tab is already active - if (panelName === this.activeTab?.panel) { + if (tabName === this.activeTabElement?.name) { return; } - const previousTab = this.activeTab; + const previousTab = this.activeTabElement; const newTab: HTMLPostTabHeaderElement = this.host.querySelector( - `post-tab-header[panel=${panelName}]`, + `post-tab-header[name="${tabName}"]`, ); this.activateTab(newTab); @@ -83,7 +118,7 @@ export class PostTabs { } // hide the currently visible panel only if no other animation is running - if (previousTab && !this.showing && !this.hiding) this.hidePanel(previousTab.panel); + if (previousTab && !this.showing && !this.hiding) this.hidePanel(previousTab.name); // wait for any hiding animation to complete before showing the selected tab if (this.hiding) await this.hiding.finished; @@ -93,7 +128,7 @@ export class PostTabs { // wait for any display animation to complete for the returned promise to fully resolve if (this.showing) await this.showing.finished; - if (this.isLoaded) this.postChange.emit(this.activeTab.panel); + if (this.isLoaded) this.postChange.emit(this.activeTabElement.name); } private moveMisplacedTabs() { @@ -111,51 +146,110 @@ export class PostTabs { this.tabs.forEach(async tab => { await componentOnReady(tab); - // if the tab has an "aria-controls" attribute it was already linked to its panel: do nothing - if (tab.getAttribute('aria-controls')) return; + if (this.mode === 'navigation') { + this.enableNavigationTab(tab); + } else { + this.enablePanelTab(tab); + } + + tab.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') this.navigateTabs(tab, e.key); + }); + }); + + // if the currently active tab was removed from the DOM then select the first one + if (this.activeTabElement && !this.activeTabElement.isConnected) { + void this.show(this.tabs[0]?.name); + } + } + + private enableNavigationTab(tab: HTMLPostTabHeaderElement) { + const anchor = tab.querySelector('a'); + + if (!anchor) return; + + // For navigation mode, we don't prevent the default anchor behavior + // The consumer handles routing, we just manage the active state + + tab.addEventListener('click', () => { + this.setActiveTab(tab.name); + }); + + // Remove panel-related attributes for navigation mode + tab.removeAttribute('aria-controls'); + tab.removeAttribute('role'); + + tab.removeAttribute('tabindex'); + } + + private enablePanelTab(tab: HTMLPostTabHeaderElement) { + // if the tab has an "aria-controls" attribute it was already linked to its panel: do nothing + if (tab.getAttribute('aria-controls')) return; - const tabPanel = this.getPanel(tab.panel); + const tabPanel = this.getPanel(tab.name); + if (tabPanel) { tab.setAttribute('aria-controls', tabPanel.id); tabPanel.setAttribute('aria-labelledby', tab.id); + } - tab.addEventListener('click', () => { - void this.show(tab.panel); - }); + tab.addEventListener('click', () => { + void this.show(tab.name); + }); - tab.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - void this.show(tab.panel); - } - }); + tab.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + void this.show(tab.name); + } + }); + } - tab.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') this.navigateTabs(tab, e.key); - }); + private setActiveTab(tabName: string) { + const newTab: HTMLPostTabHeaderElement = this.host.querySelector( + `post-tab-header[name="${tabName}"]`, + ); + + if (!newTab) return; + + this.activateTabForNavigation(newTab); + } + + private activateTabForNavigation(tab: HTMLPostTabHeaderElement) { + // Remove active state from all tabs + this.tabs.forEach(t => { + t.classList.remove('active'); + const anchor = t.querySelector('a'); + if (anchor) { + anchor.removeAttribute('aria-current'); + } }); - // if the currently active tab was removed from the DOM then select the first one - if (this.activeTab && !this.activeTab.isConnected) { - void this.show(this.tabs[0]?.panel); + // Set active state for the new tab + tab.classList.add('active'); + const anchor = tab.querySelector('a'); + if (anchor) { + anchor.setAttribute('aria-current', 'page'); } + + this.activeTabElement = tab; } private activateTab(tab: HTMLPostTabHeaderElement) { - if (this.activeTab) { - this.activeTab.setAttribute('aria-selected', 'false'); - this.activeTab.setAttribute('tabindex', '-1'); - this.activeTab.classList.remove('active'); + if (this.activeTabElement) { + this.activeTabElement.setAttribute('aria-selected', 'false'); + this.activeTabElement.setAttribute('tabindex', '-1'); + this.activeTabElement.classList.remove('active'); } tab.setAttribute('aria-selected', 'true'); tab.setAttribute('tabindex', '0'); tab.classList.add('active'); - this.activeTab = tab; + this.activeTabElement = tab; } - private hidePanel(panelName: HTMLPostTabPanelElement['name']) { - const previousPanel = this.getPanel(panelName); + private hidePanel(tabName: string) { + const previousPanel = this.getPanel(tabName); if (!previousPanel) return; @@ -167,7 +261,9 @@ export class PostTabs { } private showSelectedPanel() { - const panel = this.getPanel(this.activeTab.panel); + const panel = this.getPanel(this.activeTabElement.name); + if (!panel) return; + panel.style.display = 'block'; // prevent the initially selected panel from fading in @@ -180,7 +276,7 @@ export class PostTabs { } private getPanel(name: string): HTMLPostTabPanelElement { - return this.host.querySelector(`post-tab-panel[name=${name}]`); + return this.host.querySelector(`post-tab-panel[for="${name}"]`); } private navigateTabs(tab: HTMLPostTabHeaderElement, key: 'ArrowRight' | 'ArrowLeft') { @@ -199,17 +295,28 @@ export class PostTabs { } render() { + const TabsContainer = this.mode === 'navigation' ? 'nav' : 'div'; + const tabsRole = this.mode === 'navigation' ? undefined : 'tablist'; + const ariaLabel = this.mode === 'navigation' ? 'Tabs navigation' : undefined; + return (
-
- this.enableTabs()} /> -
-
-
- this.moveMisplacedTabs()} /> + + this.onTabsSlotChange()} /> +
+ {this.mode === 'panels' && ( +
+ this.moveMisplacedTabs()} /> +
+ )}
); } + + private onTabsSlotChange() { + this.detectMode(); + this.enableTabs(); + } } diff --git a/packages/components/src/components/post-tabs/readme.md b/packages/components/src/components/post-tabs/readme.md index a2480e498e..423846e162 100644 --- a/packages/components/src/components/post-tabs/readme.md +++ b/packages/components/src/components/post-tabs/readme.md @@ -7,31 +7,32 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | -| `activePanel` | `active-panel` | The name of the panel that is initially shown. If not specified, it defaults to the panel associated with the first tab. **Changing this value after initialization has no effect.** | `string` | `undefined` | -| `fullWidth` | `full-width` | When set to true, this property allows the tabs container to span the full width of the screen, from edge to edge. | `boolean` | `false` | +| Property | Attribute | Description | Type | Default | +| ----------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | +| `activeTab` | `active-tab` | The name of the tab that is initially active. If not specified, it defaults to the first tab. **Changing this value after initialization has no effect.** | `string` | `undefined` | +| `fullWidth` | `full-width` | When set to true, this property allows the tabs container to span the full width of the screen, from edge to edge. | `boolean` | `false` | ## Events -| Event | Description | Type | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| `postChange` | An event emitted after the active tab changes, when the fade in transition of its associated panel is finished. The payload is the name of the newly shown panel. | `CustomEvent` | +| Event | Description | Type | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `postChange` | An event emitted after the active tab changes, when the fade in transition of its associated panel is finished. The payload is the name of the newly active tab. | `CustomEvent` | ## Methods -### `show(panelName: string) => Promise` +### `show(tabName: string) => Promise` Shows the panel with the given name and selects its associated tab. +In navigation mode, only updates the active tab state. Any other panel that was previously shown becomes hidden and its associated tab is unselected. #### Parameters -| Name | Type | Description | -| ----------- | -------- | ----------- | -| `panelName` | `string` | | +| Name | Type | Description | +| --------- | -------- | ----------- | +| `tabName` | `string` | | #### Returns diff --git a/packages/documentation/src/stories/components/tabs/tabs.stories.ts b/packages/documentation/src/stories/components/tabs/tabs.stories.ts index bfc385178f..894cfd2035 100644 --- a/packages/documentation/src/stories/components/tabs/tabs.stories.ts +++ b/packages/documentation/src/stories/components/tabs/tabs.stories.ts @@ -17,8 +17,8 @@ const meta: MetaComponent = { }, }, argTypes: { - activePanel: { - name: 'active-panel', + activeTab: { + name: 'active-tab', control: 'select', options: ['first', 'second', 'third'], }, @@ -31,20 +31,20 @@ export default meta; function renderTabs(args: Partial) { return html` - First tab - Second tab - Third tab + First tab + Second tab + Third tab - + This is the content of the first tab. By default it is shown initially. - + This is the content of the second tab. By default it is hidden initially. - + This is the content of the third tab. By default it is also hidden initially. @@ -62,7 +62,7 @@ export const Default: Story = { export const ActivePanel: Story = { args: { - activePanel: 'third', + activeTab: 'third', }, }; @@ -83,8 +83,8 @@ export const Async: Story = { tabIndex++; const newTab = ` - New tab ${tabIndex} - This is the content of the new tab ${tabIndex}. + New tab ${tabIndex} + This is the content of the new tab ${tabIndex}. `; tabs?.insertAdjacentHTML('beforeend', newTab); @@ -100,7 +100,7 @@ export const Async: Story = { activeHeader?.remove(); const activePanel: HTMLPostTabPanelElement | null = - document.querySelector(`post-tab-panel[name=${activeHeader?.panel}]`) ?? null; + document.querySelector(`post-tab-panel[name=${activeHeader?.name}]`) ?? null; activePanel?.remove(); };