diff --git a/.changeset/tabs-anchor-navigation.md b/.changeset/tabs-anchor-navigation.md new file mode 100644 index 0000000000..9de0634471 --- /dev/null +++ b/.changeset/tabs-anchor-navigation.md @@ -0,0 +1,52 @@ +--- +"@swisspost/design-system-components": major +"@swisspost/design-system-components-react": major +"@swisspost/design-system-components-angular": major +--- + +Refactored `` component: +- Renamed `post-tab-header` component to `post-tab-item` +- Renamed `panel` property to `name` in `post-tab-item` component +- Renamed `name` property to `for` in `post-tab-panel` component +- Renamed `activePanel` property to `activeTab` in `post-tabs` component +- Changed slot structure: `post-tab-item` elements now use the default slot and `post-tab-panel` elements use the `panels` slot + +BEFORE: + +```html + + First tab + Second tab + Third tab + + + This is the content of the first tab. + + + This is the content of the second tab. + + + This is the content of the third tab. + + +``` + +AFTER: + +```html + + First tab + Second tab + Third tab + + + This is the content of the first tab. + + + This is the content of the second tab. + + + This is the content of the third tab. + + +``` diff --git a/.changeset/tabs-navigation-mode.md b/.changeset/tabs-navigation-mode.md new file mode 100644 index 0000000000..2298c3d851 --- /dev/null +++ b/.changeset/tabs-navigation-mode.md @@ -0,0 +1,6 @@ +--- +"@swisspost/design-system-components": minor +"@swisspost/design-system-documentation": minor +--- + +Added navigation variant to the `post-tabs` component, enabling anchor-based navigation. The component now automatically detects whether `post-tab-item` elements contain anchor links and switches between panels and navigation variants accordingly. \ No newline at end of file diff --git a/packages/components-angular/package.json b/packages/components-angular/package.json index 696ee57ac5..4f450fa544 100644 --- a/packages/components-angular/package.json +++ b/packages/components-angular/package.json @@ -49,6 +49,7 @@ "angular-eslint": "19.1.0", "copyfiles": "2.4.1", "cypress": "14.3.2", + "cypress-axe": "1.5.0", "eslint": "9.18.0", "globals": "16.0.0", "karma": "6.4.4", diff --git a/packages/components-angular/projects/consumer-app/cypress/e2e/components.cy.ts b/packages/components-angular/projects/consumer-app/cypress/e2e/components.cy.ts index 7177bed94b..864c1b7c18 100644 --- a/packages/components-angular/projects/consumer-app/cypress/e2e/components.cy.ts +++ b/packages/components-angular/projects/consumer-app/cypress/e2e/components.cy.ts @@ -1,4 +1,4 @@ -import * as Components from '@swisspost/design-system-components/dist'; +import * as Components from '@swisspost/design-system-components/loader'; const COMPONENT_TAG_NAMES = Object.keys(Components) .filter(c => /^Post([A-Z][a-z]+)+$/.test(c)) diff --git a/packages/components-angular/projects/consumer-app/cypress/e2e/tabs.cy.ts b/packages/components-angular/projects/consumer-app/cypress/e2e/tabs.cy.ts new file mode 100644 index 0000000000..7fc7e2bf6e --- /dev/null +++ b/packages/components-angular/projects/consumer-app/cypress/e2e/tabs.cy.ts @@ -0,0 +1,181 @@ +describe('Tabs', () => { + beforeEach(() => { + cy.visit('/tabs'); + cy.injectAxe(); + cy.get('post-tabs').first().as('panelTabs'); + cy.get('post-tabs').last().as('navTabs'); + }); + + describe('Panel Variant - Default', () => { + it('should render the tabs component', () => { + cy.get('@panelTabs').should('exist'); + }); + + it('should show three tab headers', () => { + cy.get('@panelTabs').find('post-tab-item').should('have.length', 3); + }); + + it('should only show the first tab header as active', () => { + cy.get('@panelTabs').find('post-tab-item').first().should('have.class', 'active'); + cy.get('@panelTabs').find('post-tab-item').eq(1).should('not.have.class', 'active'); + cy.get('@panelTabs').find('post-tab-item').eq(2).should('not.have.class', 'active'); + }); + + it('should only show the tab panel associated with the first tab header', () => { + cy.get('@panelTabs').find('post-tab-panel:visible').as('panel'); + cy.get('@panel').should('have.length', 1); + cy.get('@panelTabs') + .find('post-tab-item') + .first() + .invoke('attr', 'name') + .then(tabName => { + cy.get('@panel').invoke('attr', 'for').should('equal', tabName); + }); + }); + + it('should activate a clicked tab header and deactivate the tab header that was previously activated', () => { + cy.get('@panelTabs').find('post-tab-item').last().click(); + + cy.get('@panelTabs').find('post-tab-item').first().should('not.have.class', 'active'); + cy.get('@panelTabs').find('post-tab-item').last().should('have.class', 'active'); + }); + + it('should show the panel associated with a clicked tab header', () => { + cy.get('@panelTabs').find('post-tab-item').last().click(); + + cy.get('@panelTabs').find('post-tab-panel:visible').should('have.length', 1); + + cy.get('@panelTabs') + .find('post-tab-item') + .last() + .invoke('attr', 'name') + .then(tabName => { + cy.get('@panelTabs') + .find('post-tab-panel:visible') + .invoke('attr', 'for') + .should('equal', tabName); + }); + }); + }); + + describe('Navigation Variant', () => { + it('should render as navigation when tabs contain anchor elements', () => { + cy.get('@navTabs').should('exist'); + cy.get('@navTabs').find('post-tab-item').should('have.length', 3); + cy.get('@navTabs') + .find('post-tab-item') + .each($item => { + cy.wrap($item).find('a').should('exist'); + }); + }); + + it('should not render tab panels in navigation variant', () => { + cy.get('@navTabs').find('post-tab-panel').should('not.exist'); + cy.get('@navTabs').find('[part="content"]').should('not.exist'); + }); + + it('should render the tabs container as nav element', () => { + cy.get('@navTabs').find('nav[role="navigation"], nav').should('exist'); + }); + + it('should set proper ARIA attributes for navigation', () => { + cy.get('@navTabs').find('nav').should('have.attr', 'aria-label', 'Tabs navigation'); + }); + + it('should support programmatic tab activation via show() method', () => { + cy.get('@navTabs').then($tabs => { + const tabsElement = $tabs[0] as HTMLElement & { show: (tabName: string) => void }; + tabsElement.show('nav-second'); + }); + cy.get('@navTabs').find('post-tab-item').eq(1).should('have.class', 'active'); + }); + }); + + describe('Accessibility - Panel Variant', () => { + beforeEach(() => { + cy.get('@panelTabs').as('tabs'); + }); + + it('should have proper ARIA attributes for panels variant', () => { + cy.get('@tabs').find('[role="tablist"]').should('exist'); + cy.get('@tabs').find('post-tab-item').should('have.attr', 'role', 'tab'); + cy.get('@tabs').find('post-tab-item').should('have.attr', 'aria-selected'); + cy.get('@tabs').find('post-tab-item').first().should('have.attr', 'aria-selected', 'true'); + cy.get('@tabs').find('post-tab-item').not(':first').should('have.attr', 'aria-selected', 'false'); + }); + + it('should link tabs to panels with aria-controls and aria-labelledby', () => { + cy.get('@tabs') + .find('post-tab-item') + .first() + .then($tab => { + const tabId = $tab.attr('id'); + const ariaControls = $tab.attr('aria-controls'); + + cy.get(`post-tab-panel[id="${ariaControls}"]`).should('exist'); + cy.get(`post-tab-panel[id="${ariaControls}"]`).should( + 'have.attr', + 'aria-labelledby', + tabId, + ); + }); + }); + + it('should manage tabindex properly', () => { + cy.get('@tabs').find('post-tab-item').first().should('have.attr', 'tabindex', '0'); + cy.get('@tabs').find('post-tab-item').not(':first').should('have.attr', 'tabindex', '-1'); + + cy.get('@tabs').find('post-tab-item').last().click(); + cy.get('@tabs').find('post-tab-item').last().should('have.attr', 'tabindex', '0'); + cy.get('@tabs').find('post-tab-item').not(':last').should('have.attr', 'tabindex', '-1'); + }); + }); + + describe('Accessibility - Navigation Variant', () => { + beforeEach(() => { + cy.get('@navTabs').as('tabs'); + }); + + it('should have proper ARIA attributes for navigation variant', () => { + cy.get('@tabs').find('nav').should('have.attr', 'aria-label', 'Tabs navigation'); + cy.get('@tabs').find('post-tab-item').should('not.have.attr', 'role'); + cy.get('@tabs').find('post-tab-item').should('not.have.attr', 'tabindex'); + }); + + it('should not have tablist role in navigation variant', () => { + cy.get('@tabs').find('[role="tablist"]').should('not.exist'); + }); + }); + + describe('Variant Detection', () => { + it('should detect panel variant when no anchor elements are present', () => { + cy.get('@panelTabs').should('exist'); + cy.get('@panelTabs').find('post-tab-panel').should('exist'); + cy.get('@panelTabs').find('[part="content"]').should('exist'); + }); + + it('should detect navigation variant when anchor elements are present', () => { + cy.get('@navTabs').should('exist'); + cy.get('@navTabs').find('post-tab-panel').should('not.exist'); + cy.get('@navTabs').find('nav').should('exist'); + }); + }); + + describe('Accessibility Violations', () => { + it('should not have any automatically detectable accessibility issues in panels variant', () => { + cy.get('@panelTabs').should('be.visible'); + cy.get('@panelTabs').find('post-tab-item').first().should('be.visible'); + cy.get('@panelTabs').find('[role="tablist"]').then($tablist => { + cy.checkA11y($tablist[0]); + }); + }); + + it('should not have any automatically detectable accessibility issues in navigation variant', () => { + cy.get('@navTabs').should('be.visible'); + cy.get('@navTabs').find('post-tab-item').first().should('be.visible'); + cy.get('@navTabs').then($el => { + cy.checkA11y($el[0]); + }); + }); + }); +}); diff --git a/packages/components-angular/projects/consumer-app/cypress/support/e2e.ts b/packages/components-angular/projects/consumer-app/cypress/support/e2e.ts index 959d46bc93..48dc5963b1 100644 --- a/packages/components-angular/projects/consumer-app/cypress/support/e2e.ts +++ b/packages/components-angular/projects/consumer-app/cypress/support/e2e.ts @@ -15,3 +15,4 @@ // When a command from ./commands is ready to use, import with `import './commands'` syntax import './commands'; +import 'cypress-axe'; diff --git a/packages/components-angular/projects/consumer-app/src/app/app-routing.module.ts b/packages/components-angular/projects/consumer-app/src/app/app-routing.module.ts index 5172e3b67c..f9a43d706d 100644 --- a/packages/components-angular/projects/consumer-app/src/app/app-routing.module.ts +++ b/packages/components-angular/projects/consumer-app/src/app/app-routing.module.ts @@ -2,11 +2,13 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { HomeComponent } from './routes/home/home.component'; import { CardControlComponent } from './routes/card-control/card-control.component'; +import { TabsComponent } from './routes/tabs/tabs.component'; const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, { title: 'Home', path: 'home', component: HomeComponent }, { title: 'Card-Control', path: 'card-control', component: CardControlComponent }, + { title: 'Tabs', path: 'tabs', component: TabsComponent }, ]; @NgModule({ diff --git a/packages/components-angular/projects/consumer-app/src/app/app.module.ts b/packages/components-angular/projects/consumer-app/src/app/app.module.ts index 0fe07a924d..5927731fce 100644 --- a/packages/components-angular/projects/consumer-app/src/app/app.module.ts +++ b/packages/components-angular/projects/consumer-app/src/app/app.module.ts @@ -7,6 +7,7 @@ import { providePostComponents } from '@swisspost/design-system-components-angul import { AppComponent } from './app.component'; import { CardControlComponent } from './routes/card-control/card-control.component'; +import { TabsComponent } from './routes/tabs/tabs.component'; @NgModule({ imports: [ @@ -15,6 +16,7 @@ import { CardControlComponent } from './routes/card-control/card-control.compone AppRoutingModule, FormsModule, CardControlComponent, + TabsComponent, ], declarations: [AppComponent], providers: [providePostComponents()], diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html index fda23b2e7a..c6c3f3da28 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html @@ -82,17 +82,17 @@

Post Rating

Post Tabs

- Unua langeto - Dua langeto - Tria langeto + Unua langeto + Dua langeto + Tria langeto - + Jen la enhavo de la unua langeto. Defaŭlte ĝi montriĝas komence. - + Jen la enhavo de la dua langeto. Defaŭlte ĝi estas kaŝita komence. - + Jen la enhavo de la tria langeto. Defaŭlte ĝi ankaŭ estas kaŝita komence. diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts index a59c219b13..344df05906 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts @@ -13,7 +13,7 @@ import { PostPopovercontainer, PostRating, PostTabs, - PostTabHeader, + PostTabItem, PostTabPanel, PostTooltipTrigger, } from 'components'; @@ -36,7 +36,7 @@ import { PostPopovercontainer, PostRating, PostTabs, - PostTabHeader, + PostTabItem, PostTabPanel, PostTooltipTrigger, ] diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/tabs/tabs.component.html b/packages/components-angular/projects/consumer-app/src/app/routes/tabs/tabs.component.html new file mode 100644 index 0000000000..2d8b478d2d --- /dev/null +++ b/packages/components-angular/projects/consumer-app/src/app/routes/tabs/tabs.component.html @@ -0,0 +1,28 @@ +

Tabs - Panel Variant

+ + First + Second + Third + +

Content of first tab

+
+ +

Content of second tab

+
+ +

Content of third tab

+
+
+ +

Tabs - Navigation Variant

+ + + First + + + Second + + + Third + + diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/tabs/tabs.component.ts b/packages/components-angular/projects/consumer-app/src/app/routes/tabs/tabs.component.ts new file mode 100644 index 0000000000..26cc1886be --- /dev/null +++ b/packages/components-angular/projects/consumer-app/src/app/routes/tabs/tabs.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { PostTabs, PostTabItem, PostTabPanel } from '@swisspost/design-system-components-angular'; + +@Component({ + selector: 'tabs-page', + templateUrl: './tabs.component.html', + imports: [CommonModule, ReactiveFormsModule, PostTabs, PostTabItem, PostTabPanel], + standalone: true, +}) +export class TabsComponent {} diff --git a/packages/components/cypress/e2e/tabs.cy.ts b/packages/components/cypress/e2e/tabs.cy.ts index 0b302db62a..8247f2b4e7 100644 --- a/packages/components/cypress/e2e/tabs.cy.ts +++ b/packages/components/cypress/e2e/tabs.cy.ts @@ -4,7 +4,7 @@ describe('tabs', () => { describe('default', () => { beforeEach(() => { cy.getComponent('tabs', TABS_ID); - cy.get('post-tab-header').as('headers'); + cy.get('post-tab-item').as('tabItems'); }); it('should render', () => { @@ -12,11 +12,11 @@ describe('tabs', () => { }); it('should show three tab headers', () => { - cy.get('@headers').should('have.length', 3); + cy.get('@tabItems').should('have.length', 3); }); it('should only show the first tab header as active', () => { - cy.get('post-tab-header.active').each(($header, index) => { + cy.get('post-tab-item.active').each(($header, index) => { cy.wrap($header).should(index === 0 ? 'exist' : 'not.exist'); }); }); @@ -24,64 +24,62 @@ describe('tabs', () => { it('should only show the tab panel associated with the first tab header', () => { cy.get('post-tab-panel:visible').as('panel'); cy.get('@panel').should('have.length', 1); - cy.get('@headers') + cy.get('@tabItems') .first() - .invoke('attr', 'panel') - .then(panel => { - cy.get('@panel').invoke('attr', 'name').should('equal', panel); + .invoke('attr', 'name') + .then(tabName => { + cy.get('@panel').invoke('attr', 'for').should('equal', tabName); }); }); it('should activate a clicked tab header and deactivate the tab header that was previously activated', () => { - cy.get('@headers').last().click(); + cy.get('@tabItems').last().click(); - cy.get('@headers').first().should('not.have.class', 'active'); - cy.get('@headers').last().should('have.class', 'active'); + cy.get('@tabItems').first().should('not.have.class', 'active'); + cy.get('@tabItems').last().should('have.class', 'active'); }); it('should show the panel associated with a clicked tab header and hide the panel that was previously shown', () => { - cy.get('@headers').last().click(); + cy.get('@tabItems').last().click(); - // wait for the fade out animation to complete - cy.wait(200); - - cy.get('post-tab-panel:visible').as('panel'); - cy.get('@panel').should('have.length', 1); - cy.get('@headers') + // Wait for transition to complete + cy.get('post-tab-panel:visible').should('have.length', 1); + + cy.get('@tabItems') .last() - .invoke('attr', 'panel') - .then(panel => { - cy.get('@panel').invoke('attr', 'name').should('equal', panel); + .invoke('attr', 'name') + .then(tabName => { + cy.get('post-tab-panel:visible').invoke('attr', 'for').should('equal', tabName); }); }); }); describe('active panel', () => { beforeEach(() => { - cy.getComponent('tabs', TABS_ID, 'active-panel'); - cy.get('post-tab-header').as('headers'); + cy.getComponent('tabs', TABS_ID, 'active-tab'); + cy.get('post-tab-item').as('tabItems'); cy.get('post-tab-panel:visible').as('panel'); }); it('should only show the requested active tab panel', () => { cy.get('@panel').should('have.length', 1); cy.get('@tabs') - .invoke('attr', 'active-panel') - .then(activePanel => { - cy.get('@panel').invoke('attr', 'name').should('equal', activePanel); + .invoke('attr', 'active-tab') + .then(activeTab => { + cy.get('@panel').invoke('attr', 'for').should('equal', activeTab); }); }); it('should show as active only the tab header associated with the requested active tab panel', () => { cy.get('@tabs') - .invoke('attr', 'active-panel') - .then(activePanel => { - cy.get('@headers').each($header => { + .invoke('attr', 'active-tab') + .then(activeTab => { + cy.get('@tabItems').each($header => { cy.wrap($header) - .invoke('attr', 'panel') - .then(panel => { + .invoke('attr', 'name') + .then(tabName => { cy.wrap($header.filter('.active')).should( - panel === activePanel ? 'exist' : 'not.exist', + tabName === activeTab ? 'exist' : 'not.exist', ); }); }); @@ -92,12 +90,12 @@ describe('tabs', () => { describe('async', () => { beforeEach(() => { cy.getComponent('tabs', TABS_ID, 'async'); - cy.get('post-tab-header').as('headers'); + cy.get('post-tab-item').as('tabItems'); }); it('should add a tab header', () => { cy.get('#add-tab').click(); - cy.get('@headers').should('have.length', 4); + cy.get('@tabItems').should('have.length', 4); }); it('should still show the tab panel associated with the first tab header after adding new tab', () => { @@ -105,46 +103,43 @@ describe('tabs', () => { cy.get('post-tab-panel:visible').as('panel'); cy.get('@panel').should('have.length', 1); - cy.get('@headers') + cy.get('@tabItems') .first() - .invoke('attr', 'panel') - .then(panel => { - cy.get('@panel').invoke('attr', 'name').should('equal', panel); + .invoke('attr', 'name') + .then(tabName => { + cy.get('@panel').invoke('attr', 'for').should('equal', tabName); }); }); it('should activate the newly added tab header after clicking on it', () => { cy.get('#add-tab').click(); - cy.get('post-tab-header').as('headers'); - cy.get('@headers').last().click(); + cy.get('post-tab-item').as('tabItems'); + cy.get('@tabItems').last().click(); - cy.get('@headers').first().should('not.have.class', 'active'); - cy.get('@headers').last().should('have.class', 'active'); + cy.get('@tabItems').first().should('not.have.class', 'active'); + cy.get('@tabItems').last().should('have.class', 'active'); }); it('should display the tab panel associated with the newly added tab after clicking on it', () => { cy.get('#add-tab').click(); - cy.get('post-tab-header').last().as('new-panel'); - cy.get('@new-panel').click(); - - // wait for the fade out animation to complete - cy.wait(200); + cy.get('post-tab-item').last().as('new-tab'); + cy.get('@new-tab').click(); - cy.get('post-tab-panel:visible').as('panel'); - cy.get('@panel').should('have.length', 1); - cy.get('@new-panel') - .invoke('attr', 'panel') - .then(panel => { - cy.get('@panel').invoke('attr', 'name').should('equal', panel); + cy.get('post-tab-panel:visible').should('have.length', 1); + + cy.get('@new-tab') + .invoke('attr', 'name') + .then(tabName => { + cy.get('post-tab-panel:visible').invoke('attr', 'for').should('equal', tabName); }); }); it('should remove a tab header', () => { cy.get('.tab-title.active').then(() => { cy.get('#remove-active-tab').click(); - cy.get('@headers').should('have.length', 2); + cy.get('@tabItems').should('have.length', 2); }); }); @@ -162,6 +157,71 @@ describe('tabs', () => { }); }); }); + + describe('navigation mode', () => { + beforeEach(() => { + cy.getComponent('tabs', TABS_ID, 'navigation-variant'); + cy.get('post-tab-item').as('tabItems'); + }); + + it('should render as navigation when tabs contain anchor elements', () => { + cy.get('@tabs').should('exist'); + cy.get('@tabItems').should('have.length', 3); + cy.get('@tabItems').each($item => { + cy.wrap($item).find('a').should('exist'); + }); + }); + + it('should not render tab panels in navigation mode', () => { + cy.get('post-tab-panel').should('not.exist'); + cy.get('@tabs').find('[part="content"]').should('not.exist'); + }); + + it('should render the tabs container as nav element', () => { + cy.get('@tabs').find('nav[role="navigation"], nav').should('exist'); + }); + + it('should set proper ARIA attributes for navigation', () => { + cy.get('@tabs').find('nav').should('have.attr', 'aria-label', 'Tabs navigation'); + }); + + it('should support programmatic tab activation via show() method', () => { + cy.get('@tabs').then($tabs => { + const tabsElement = $tabs[0] as HTMLElement & { show: (tabName: string) => void }; + tabsElement.show('second'); + }); + cy.get('@tabItems').eq(1).should('have.class', 'active'); + }); + + it('should detect active tab based on aria-current="page"', () => { + cy.getComponent('tabs', TABS_ID, 'navigation-with-current'); + + cy.get('post-tab-item').eq(1).should('have.class', 'active'); + }); + }); + + describe('mode detection', () => { + it('should detect panels mode when no anchor elements are present', () => { + cy.getComponent('tabs', TABS_ID, 'default'); + cy.get('post-tabs').should('exist'); + cy.get('post-tab-panel').should('exist'); + cy.get('post-tabs').find('[part="content"]').should('exist'); + }); + + it('should detect navigation mode when anchor elements are present', () => { + cy.getComponent('tabs', TABS_ID, 'navigation-variant'); + cy.get('post-tabs').should('exist'); + cy.get('post-tab-panel').should('not.exist'); + cy.get('post-tabs').find('nav').should('exist'); + }); + + it('should handle mixed mode usage', () => { + cy.getComponent('tabs', TABS_ID, 'mixed-mode'); + + cy.get('post-tabs').should('exist'); + cy.get('post-tab-item').should('exist'); + }); + }); }); describe('Accessibility', () => { @@ -169,4 +229,53 @@ describe('Accessibility', () => { cy.getSnapshots('tabs'); cy.checkA11y('#root-inner'); }); -}); + + describe('panels mode ARIA attributes', () => { + beforeEach(() => { + cy.getComponent('tabs', TABS_ID, 'default'); + }); + + it('should have proper ARIA attributes for panels mode', () => { + cy.get('post-tabs').find('[role="tablist"]').should('exist'); + cy.get('post-tab-item').should('have.attr', 'role', 'tab'); + cy.get('post-tab-item').should('have.attr', 'aria-selected'); + cy.get('post-tab-item').first().should('have.attr', 'aria-selected', 'true'); + cy.get('post-tab-item').not(':first').should('have.attr', 'aria-selected', 'false'); + }); + + it('should link tabs to panels with aria-controls and aria-labelledby', () => { + cy.get('post-tab-item').first().then($tab => { + const tabId = $tab.attr('id'); + const ariaControls = $tab.attr('aria-controls'); + + cy.get(`post-tab-panel[id="${ariaControls}"]`).should('exist'); + cy.get(`post-tab-panel[id="${ariaControls}"]`).should('have.attr', 'aria-labelledby', tabId); + }); + }); + + it('should manage tabindex properly', () => { + cy.get('post-tab-item').first().should('have.attr', 'tabindex', '0'); + cy.get('post-tab-item').not(':first').should('have.attr', 'tabindex', '-1'); + + cy.get('post-tab-item').last().click(); + cy.get('post-tab-item').last().should('have.attr', 'tabindex', '0'); + cy.get('post-tab-item').not(':last').should('have.attr', 'tabindex', '-1'); + }); + }); + + describe('navigation mode ARIA attributes', () => { + beforeEach(() => { + cy.getComponent('tabs', TABS_ID, 'navigation-variant'); + }); + + it('should have proper ARIA attributes for navigation mode', () => { + cy.get('post-tabs').find('nav').should('have.attr', 'aria-label', 'Tabs navigation'); + cy.get('post-tab-item').should('not.have.attr', 'role'); + cy.get('post-tab-item').should('not.have.attr', 'tabindex'); + }); + + it('should not have tablist role in navigation mode', () => { + cy.get('post-tabs').find('[role="tablist"]').should('not.exist'); + }); + }); +}); \ No newline at end of file diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index d2cca62794..f745ceafb3 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -450,32 +450,32 @@ export namespace Components { */ "stars": number; } - interface PostTabHeader { + interface PostTabItem { /** - * 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 { /** @@ -840,11 +840,11 @@ declare global { prototype: HTMLPostRatingElement; new (): HTMLPostRatingElement; }; - interface HTMLPostTabHeaderElement extends Components.PostTabHeader, HTMLStencilElement { + interface HTMLPostTabItemElement extends Components.PostTabItem, HTMLStencilElement { } - var HTMLPostTabHeaderElement: { - prototype: HTMLPostTabHeaderElement; - new (): HTMLPostTabHeaderElement; + var HTMLPostTabItemElement: { + prototype: HTMLPostTabItemElement; + new (): HTMLPostTabItemElement; }; interface HTMLPostTabPanelElement extends Components.PostTabPanel, HTMLStencilElement { } @@ -917,7 +917,7 @@ declare global { "post-popover": HTMLPostPopoverElement; "post-popovercontainer": HTMLPostPopovercontainerElement; "post-rating": HTMLPostRatingElement; - "post-tab-header": HTMLPostTabHeaderElement; + "post-tab-item": HTMLPostTabItemElement; "post-tab-panel": HTMLPostTabPanelElement; "post-tabs": HTMLPostTabsElement; "post-togglebutton": HTMLPostTogglebuttonElement; @@ -1303,30 +1303,30 @@ declare namespace LocalJSX { */ "stars"?: number; } - interface PostTabHeader { + interface PostTabItem { /** - * 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; } @@ -1399,7 +1399,7 @@ declare namespace LocalJSX { "post-popover": PostPopover; "post-popovercontainer": PostPopovercontainer; "post-rating": PostRating; - "post-tab-header": PostTabHeader; + "post-tab-item": PostTabItem; "post-tab-panel": PostTabPanel; "post-tabs": PostTabs; "post-togglebutton": PostTogglebutton; @@ -1446,7 +1446,7 @@ declare module "@stencil/core" { "post-popover": LocalJSX.PostPopover & JSXBase.HTMLAttributes; "post-popovercontainer": LocalJSX.PostPopovercontainer & JSXBase.HTMLAttributes; "post-rating": LocalJSX.PostRating & JSXBase.HTMLAttributes; - "post-tab-header": LocalJSX.PostTabHeader & JSXBase.HTMLAttributes; + "post-tab-item": LocalJSX.PostTabItem & JSXBase.HTMLAttributes; "post-tab-panel": LocalJSX.PostTabPanel & JSXBase.HTMLAttributes; "post-tabs": LocalJSX.PostTabs & JSXBase.HTMLAttributes; "post-togglebutton": LocalJSX.PostTogglebutton & JSXBase.HTMLAttributes; 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 deleted file mode 100644 index 7aa08e7689..0000000000 --- a/packages/components/src/components/post-tab-header/post-tab-header.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core'; -import { version } from '@root/package.json'; -import { checkRequiredAndType } from '@/utils'; -import { nanoid } from 'nanoid'; - -/** - * @slot default - Slot for the content of the tab header. - */ - -@Component({ - tag: 'post-tab-header', - styleUrl: 'post-tab-header.scss', - shadow: true, -}) -export class PostTabHeader { - @Element() host: HTMLPostTabHeaderElement; - - @State() tabId: string; - - /** - * The name of the panel controlled by the tab header. - */ - @Prop({ reflect: true }) readonly panel!: string; - - @Watch('panel') - validateFor() { - checkRequiredAndType(this, 'panel', 'string'); - } - - componentWillLoad() { - this.tabId = `tab-${this.host.id || nanoid(6)}`; - } - - render() { - return ( - - - - ); - } -} diff --git a/packages/components/src/components/post-tab-header/readme.md b/packages/components/src/components/post-tab-header/readme.md deleted file mode 100644 index 6d09529475..0000000000 --- a/packages/components/src/components/post-tab-header/readme.md +++ /dev/null @@ -1,24 +0,0 @@ -# post-tab-header - - - - - - -## Properties - -| Property | Attribute | Description | Type | Default | -| -------------------- | --------- | --------------------------------------------------- | -------- | ----------- | -| `panel` _(required)_ | `panel` | The name of the panel controlled by the tab header. | `string` | `undefined` | - - -## Slots - -| Slot | Description | -| ----------- | --------------------------------------- | -| `"default"` | Slot for the content of the tab header. | - - ----------------------------------------------- - -*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/components/src/components/post-tab-header/post-tab-header.scss b/packages/components/src/components/post-tab-item/post-tab-item.scss similarity index 100% rename from packages/components/src/components/post-tab-header/post-tab-header.scss rename to packages/components/src/components/post-tab-item/post-tab-item.scss diff --git a/packages/components/src/components/post-tab-item/post-tab-item.tsx b/packages/components/src/components/post-tab-item/post-tab-item.tsx new file mode 100644 index 0000000000..8df7a683ed --- /dev/null +++ b/packages/components/src/components/post-tab-item/post-tab-item.tsx @@ -0,0 +1,62 @@ +import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core'; +import { version } from '@root/package.json'; +import { checkRequiredAndType } from '@/utils'; +import { nanoid } from 'nanoid'; + +/** + * @slot default - Slot for the content of the tab item. Can contain text or an element for navigation mode. + */ + +@Component({ + tag: 'post-tab-item', + styleUrl: 'post-tab-item.scss', + shadow: true, +}) +export class PostTabItem { + @Element() host: HTMLPostTabItemElement; + + @State() tabId: string; + @State() isNavigationMode = false; + + /** + * 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 name!: 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 isPanelMode = !this.isNavigationMode; + return ( + + + + ); + } +} diff --git a/packages/components/src/components/post-tab-item/readme.md b/packages/components/src/components/post-tab-item/readme.md new file mode 100644 index 0000000000..7b366c28ed --- /dev/null +++ b/packages/components/src/components/post-tab-item/readme.md @@ -0,0 +1,24 @@ +# post-tab-item + + + + + + +## Properties + +| 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 item. Can contain text or an element for navigation mode. | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* 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..c3849100ca 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,23 +18,24 @@ 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)}`; } render() { return ( - + ); 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..6805bd5ff4 100644 --- a/packages/components/src/components/post-tabs/post-tabs.tsx +++ b/packages/components/src/components/post-tabs/post-tabs.tsx @@ -1,13 +1,13 @@ -import { Component, Element, Event, EventEmitter, h, Host, Method, Prop } from '@stencil/core'; +import { Component, Element, Event, EventEmitter, h, Host, Method, Prop, State } from '@stencil/core'; import { version } from '@root/package.json'; import { fadeIn, fadeOut } from '@/animations'; import { componentOnReady } from '@/utils'; /** - * @slot tabs - Slot for placing tab headers. Each tab header should be a element. - * @slot default - Slot for placing tab panels. Each tab panel should be a element. + * @slot default - Slot for placing tab items. Each tab item should be a element. + * @slot panels - Slot for placing tab panels. Each tab panel should be a element. * @part tabs - The container element that holds the set of tabs. - * @part content - The container element that displays the content of the currently active tab. + * @part content - The container element that displays the content of the currently active tab. Only available in panels mode. */ @Component({ @@ -16,26 +16,35 @@ import { componentOnReady } from '@/utils'; shadow: true, }) export class PostTabs { - private activeTab: HTMLPostTabHeaderElement; + private currentActiveTab: HTMLPostTabItemElement; private showing: Animation; private hiding: Animation; private isLoaded = false; + private contentObserver: MutationObserver; - private get tabs(): HTMLPostTabHeaderElement[] { + @State() isNavigationMode: boolean = false; + + private get tabs(): HTMLPostTabItemElement[] { return Array.from( - this.host.querySelectorAll('post-tab-header'), + this.host.querySelectorAll('post-tab-item'), ).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?: string; /** * When set to true, this property allows the tabs container to span the @@ -45,37 +54,132 @@ 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.detectMode(); this.moveMisplacedTabs(); + this.isLoaded = true; this.enableTabs(); + this.setupContentObserver(); - const initiallyActivePanel = this.activePanel || this.tabs[0]?.panel; - void this.show(initiallyActivePanel); + if (this.isNavigationMode) { + const activeTab = this.findActiveNavigationTab(); + if (activeTab) { + void this.show(activeTab.name); + } + } else { + const initiallyActiveTab = this.activeTab || this.tabs[0]?.name; + void this.show(initiallyActiveTab); + } + } - this.isLoaded = true; + disconnectedCallback() { + if (this.showing) { + this.showing.cancel(); + this.showing = null; + } + if (this.hiding) { + this.hiding.cancel(); + this.hiding = null; + } + + // Clean up content observer + this.contentObserver.disconnect(); + } + + private setupContentObserver() { + const config: MutationObserverInit = { + childList: true, // Watch for child elements being added/removed + subtree: true, // Watch all descendants + attributes: true, // Watch for attribute changes + attributeFilter: ['data-navigation-mode'] // Only watch navigation mode changes + }; + + this.contentObserver = new MutationObserver(this.handleContentChange.bind(this)); + this.contentObserver.observe(this.host, config); + } + + private handleContentChange(mutations: MutationRecord[]) { + // Check if any mutations affect navigation mode + const shouldRedetect = mutations.some(mutation => { + // Child nodes added/removed (new tab items or anchor elements) + if (mutation.type === 'childList') { + return mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0; + } + // Navigation mode attribute changed + if (mutation.type === 'attributes' && mutation.attributeName === 'data-navigation-mode') { + return true; + } + return false; + }); + + if (shouldRedetect) { + // Re-detect mode and re-enable tabs if needed + const previousMode = this.isNavigationMode; + this.detectMode(); + + // If mode changed, re-initialize + if (previousMode !== this.isNavigationMode) { + this.enableTabs(); + } + } + } + + private detectMode() { + const hasNavigationTabs = this.tabs.some(tab => { + const navMode = tab.getAttribute('data-navigation-mode') === 'true'; + return navMode; + }); + + const hasPanels = this.panels.length > 0; + + if (hasNavigationTabs && hasPanels) { + throw new Error('PostTabs: Mixed mode detected. Cannot use both navigation mode (anchor elements) and panel mode (post-tab-panel elements) at the same time.'); + } + + this.isNavigationMode = hasNavigationTabs; + } + + private findActiveNavigationTab(): HTMLPostTabItemElement | null { + return this.tabs.find(tab => { + const anchor = tab.querySelector('a[aria-current="page"]'); + return anchor !== null; + }) || null; } /** * 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) { // do nothing if the tab is already active - if (panelName === this.activeTab?.panel) { + if (tabName === this.currentActiveTab?.name) { return; } - const previousTab = this.activeTab; - const newTab: HTMLPostTabHeaderElement = this.host.querySelector( - `post-tab-header[panel=${panelName}]`, + const previousTab = this.currentActiveTab; + const newTab: HTMLPostTabItemElement = this.host.querySelector( + `post-tab-item[name=${tabName}]`, ); + + if (!newTab) { + console.warn(`PostTabs: No tab found with name "${tabName}"`); + return; + } + this.activateTab(newTab); + // In navigation mode, we don't need to handle panels + if (this.isNavigationMode) { + if (this.isLoaded) this.postChange.emit(this.currentActiveTab.name); + return; + } + // if a panel is currently being displayed, remove it from the view and complete the associated animation if (this.showing) { this.showing.effect['target'].style.display = 'none'; @@ -83,49 +187,61 @@ 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; - this.showSelectedPanel(); + if (!this.isNavigationMode) { + this.showSelectedPanel(); + } // 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.currentActiveTab.name); } private moveMisplacedTabs() { if (!this.tabs) return; this.tabs.forEach(tab => { - if (tab.getAttribute('slot') === 'tabs') return; - tab.setAttribute('slot', 'tabs'); + // Tab items should go in the default slot, so remove any slot attribute + if (tab.getAttribute('slot')) { + tab.removeAttribute('slot'); + } }); } private enableTabs() { + // Prevent early call before detectMode() + if (!this.isLoaded) return; + if (!this.tabs) return; 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 (this.isNavigationMode) { + return; + } + if (tab.getAttribute('aria-controls')) return; - const tabPanel = this.getPanel(tab.panel); - tab.setAttribute('aria-controls', tabPanel.id); - tabPanel.setAttribute('aria-labelledby', tab.id); + 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); + void this.show(tab.name); }); tab.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - void this.show(tab.panel); + void this.show(tab.name); } }); @@ -133,28 +249,35 @@ export class PostTabs { 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.activeTab && !this.activeTab.isConnected) { - void this.show(this.tabs[0]?.panel); + if (this.currentActiveTab && !this.currentActiveTab.isConnected) { + void this.show(this.tabs[0]?.name); } } - private activateTab(tab: HTMLPostTabHeaderElement) { - if (this.activeTab) { - this.activeTab.setAttribute('aria-selected', 'false'); - this.activeTab.setAttribute('tabindex', '-1'); - this.activeTab.classList.remove('active'); + private activateTab(tab: HTMLPostTabItemElement) { + if (this.currentActiveTab) { + this.currentActiveTab.setAttribute('aria-selected', 'false'); + if (!this.isNavigationMode) { + this.currentActiveTab.setAttribute('tabindex', '-1'); + } else { + this.currentActiveTab.removeAttribute('tabindex'); + } + this.currentActiveTab.classList.remove('active'); } tab.setAttribute('aria-selected', 'true'); - tab.setAttribute('tabindex', '0'); + if (!this.isNavigationMode) { + tab.setAttribute('tabindex', '0'); + } else { + tab.removeAttribute('tabindex'); + } tab.classList.add('active'); - this.activeTab = tab; + this.currentActiveTab = tab; } - private hidePanel(panelName: HTMLPostTabPanelElement['name']) { + private hidePanel(panelName: HTMLPostTabPanelElement['for']) { const previousPanel = this.getPanel(panelName); if (!previousPanel) return; @@ -167,7 +290,7 @@ export class PostTabs { } private showSelectedPanel() { - const panel = this.getPanel(this.activeTab.panel); + const panel = this.getPanel(this.currentActiveTab.name); panel.style.display = 'block'; // prevent the initially selected panel from fading in @@ -180,13 +303,13 @@ 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') { + private navigateTabs(tab: HTMLPostTabItemElement, key: 'ArrowRight' | 'ArrowLeft') { const activeTabIndex = Array.from(this.tabs).indexOf(tab); - let nextTab: HTMLPostTabHeaderElement; + let nextTab: HTMLPostTabItemElement; if (key === 'ArrowRight') { nextTab = this.tabs[activeTabIndex + 1] || this.tabs[0]; } else { @@ -199,16 +322,22 @@ export class PostTabs { } render() { + const tabsRole = this.isNavigationMode ? undefined : 'tablist'; + const ariaLabel = this.isNavigationMode ? 'Tabs navigation' : undefined; + const TabsContainer = this.isNavigationMode ? 'nav' : 'div'; + return (
-
- this.enableTabs()} /> -
-
-
- this.moveMisplacedTabs()} /> + + this.enableTabs()} /> +
+ {!this.isNavigationMode && ( +
+ this.moveMisplacedTabs()} /> +
+ )}
); } diff --git a/packages/components/src/components/post-tabs/readme.md b/packages/components/src/components/post-tabs/readme.md index a2480e498e..e7d1da5e19 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 @@ -42,18 +43,18 @@ Type: `Promise` ## Slots -| Slot | Description | -| ----------- | ------------------------------------------------------------------------------------ | -| `"default"` | Slot for placing tab panels. Each tab panel should be a element. | -| `"tabs"` | Slot for placing tab headers. Each tab header should be a element. | +| Slot | Description | +| ----------- | --------------------------------------------------------------------------------- | +| `"default"` | Slot for placing tab items. Each tab item should be a element. | +| `"panels"` | Slot for placing tab panels. Each tab panel should be a element. | ## Shadow Parts -| Part | Description | -| ----------- | ---------------------------------------------------------------------------- | -| `"content"` | The container element that displays the content of the currently active tab. | -| `"tabs"` | The container element that holds the set of tabs. | +| Part | Description | +| ----------- | ----------------------------------------------------------------------------------------------------------- | +| `"content"` | The container element that displays the content of the currently active tab. Only available in panels mode. | +| `"tabs"` | The container element that holds the set of tabs. | ---------------------------------------------- diff --git a/packages/documentation/.storybook/styles/components/tabs.scss b/packages/documentation/.storybook/styles/components/tabs.scss index ea9b951dc9..d209b4989c 100644 --- a/packages/documentation/.storybook/styles/components/tabs.scss +++ b/packages/documentation/.storybook/styles/components/tabs.scss @@ -14,7 +14,7 @@ tokens.$default-map: utilities.$post-spacing; position: relative; } - > post-tab-header { + > post-tab-item { border-top-left-radius: post.$border-radius; border-top-right-radius: post.$border-radius; @@ -28,7 +28,7 @@ tokens.$default-map: utilities.$post-spacing; } } - ~ post-tab-header { + ~ post-tab-item { margin-left: 1px; } } @@ -52,7 +52,7 @@ tokens.$default-map: utilities.$post-spacing; } &:not(.sb-tabs-lg, :has(post-tab-panel > .sbdocs)) { - > post-tab-header { + > post-tab-item { font-size: 0.875rem; } diff --git a/packages/documentation/cypress/snapshots/components/tabs.snapshot.ts b/packages/documentation/cypress/snapshots/components/tabs.snapshot.ts index 5c8f0072eb..fa35949079 100644 --- a/packages/documentation/cypress/snapshots/components/tabs.snapshot.ts +++ b/packages/documentation/cypress/snapshots/components/tabs.snapshot.ts @@ -1,7 +1,7 @@ describe('Tabs', () => { it('default', () => { cy.visit('/iframe.html?id=snapshots--tabs'); - cy.get('post-tab-header[data-hydrated]', { timeout: 30000 }).should('be.visible'); + cy.get('post-tab-item[data-hydrated]', { timeout: 30000 }).should('be.visible'); cy.percySnapshot('Tabs', { widths: [320, 600, 1440] }); }); }); diff --git a/packages/documentation/src/shared/packages-docs-layout.mdx b/packages/documentation/src/shared/packages-docs-layout.mdx index 5b4a8c3900..a28a8fe0a8 100644 --- a/packages/documentation/src/shared/packages-docs-layout.mdx +++ b/packages/documentation/src/shared/packages-docs-layout.mdx @@ -2,14 +2,14 @@ import { Markdown } from '@storybook/addon-docs/blocks'; import { CodeOrSourceMdx } from '@/utils/codeOrSourceMdx'; - Instructions - + Instructions + {/* eslint-disable-next-line no-undef */} {props.children} - Changelog - + Changelog + {/* eslint-disable-next-line no-undef */} {props.changelog} diff --git a/packages/documentation/src/stories/components/card-control/card-control.docs.mdx b/packages/documentation/src/stories/components/card-control/card-control.docs.mdx index 20af593fc5..e544054c2b 100644 --- a/packages/documentation/src/stories/components/card-control/card-control.docs.mdx +++ b/packages/documentation/src/stories/components/card-control/card-control.docs.mdx @@ -26,8 +26,8 @@ import SampleCardControlMethods from './web-component/card-control-methods.sampl - Standard HTML - + Standard HTML +
@@ -74,8 +74,8 @@ import SampleCardControlMethods from './web-component/card-control-methods.sampl -Webcomponent - +Webcomponent + diff --git a/packages/documentation/src/stories/components/card-product/card-product.docs.mdx b/packages/documentation/src/stories/components/card-product/card-product.docs.mdx index d14a1537f3..40d6d9bc41 100644 --- a/packages/documentation/src/stories/components/card-product/card-product.docs.mdx +++ b/packages/documentation/src/stories/components/card-product/card-product.docs.mdx @@ -45,13 +45,13 @@ For presenting a collection of products with comparable features, use cards with - Vanilla JavaScript - + Vanilla JavaScript + -Angular Implementation - +Angular Implementation + diff --git a/packages/documentation/src/stories/components/tabs/tabs.docs.mdx b/packages/documentation/src/stories/components/tabs/tabs.docs.mdx index 6f3f4d3fc2..ddc5717652 100644 --- a/packages/documentation/src/stories/components/tabs/tabs.docs.mdx +++ b/packages/documentation/src/stories/components/tabs/tabs.docs.mdx @@ -22,11 +22,9 @@ import SampleCustomTrigger from './tabs-custom-trigger.sample?raw'; ### Initially Active Tab -The initial selection is set to the first tab by default, displaying its associated panel. -To have a different panel displayed initially, -use the `active-panel` property and assign it the name of the desired panel. +The initial selection is set to the first tab by default. To have a different tab active initially, use the `active-tab` property and assign it the name of the desired tab. - + ### Full-width Tabs @@ -43,12 +41,12 @@ Setting the `full-width` property to `true` will allow the tabs container stretc ### Tab Changes -Use the `tabChange` event to run a function anytime a new tab gets activate. +Use the `postChange` event to run a function anytime a new tab gets activated. ### Custom Trigger -To trigger a tab change, use the `show()` method specifying the name of the panel that you want to show. +To trigger a tab change, use the `show()` method specifying the name of the tab to activate and show its content. diff --git a/packages/documentation/src/stories/components/tabs/tabs.stories.ts b/packages/documentation/src/stories/components/tabs/tabs.stories.ts index bfc385178f..fc8ee9e4e4 100644 --- a/packages/documentation/src/stories/components/tabs/tabs.stories.ts +++ b/packages/documentation/src/stories/components/tabs/tabs.stories.ts @@ -1,9 +1,10 @@ import { StoryObj } from '@storybook/web-components-vite'; import { html, nothing } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { MetaComponent } from '@root/types'; -const meta: MetaComponent = { +const meta: MetaComponent = { id: 'bb1291ca-4dbb-450c-a15f-596836d9f39e', title: 'Components/Tabs', tags: ['package:WebComponents'], @@ -17,34 +18,150 @@ const meta: MetaComponent = { }, }, argTypes: { - activePanel: { - name: 'active-panel', + variant: { + name: 'variant', + description: 'Select between panels variant (content sections) or navigation variant (page navigation).

If you attempt to mix both variants(anchors + panels), the component will throw an error.

', + control: 'radio', + options: ['panels', 'navigation'], + table: { + category: 'Component Variant', + defaultValue: { summary: 'panels' }, + }, + }, + activeTab: { + name: 'active-tab', + description: 'The name of the initially active tab', control: 'select', options: ['first', 'second', 'third'], + if: { arg: 'variant' }, + table: { + category: 'Properties', + }, + }, + fullWidth: { + name: 'full-width', + description: 'Stretch tabs container to full screen width', + control: 'boolean', + table: { + category: 'Properties', + }, + }, + 'slots-default': { + name: 'default', + description: 'Slot for tab items. Available in both variants - for tab navigation buttons in both panels and navigation modes.', + control: { + type: 'text', + }, + table: { + category: 'Slots', + type: { + summary: 'HTML', + }, + }, + }, + 'slots-panels': { + name: 'panels', + description: 'Slot for tab panels content. Only available in panels variant for customizing panel content.', + control: { + type: 'text', + }, + if: { arg: 'variant', eq: 'panels' }, + table: { + category: 'Slots', + type: { + summary: 'HTML', + }, + }, }, + + }, + args: { + variant: 'panels', + fullWidth: false, + 'slots-default': '', + 'slots-panels': '', }, - args: { fullWidth: false }, }; export default meta; -function renderTabs(args: Partial) { +function renderTabs(args: Partial) { + const variant = args.variant || 'panels'; + + if (variant === 'navigation') { + if (args['slots-default']) { + return html` + + ${unsafeHTML(args['slots-default'])} + + `; + } + + return html` + + +
First page + + + Second page + + + Third page + + + `; + } + + // Panels variant (default) + if (args['slots-default']) { + // Use custom slot content if provided (complete custom content) + return html` + + ${unsafeHTML(args['slots-default'])} + + `; + } + + if (args['slots-panels']) { + return html` + + First tab + Second tab + Third tab + + ${unsafeHTML(args['slots-panels'])} + + `; + } + 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. @@ -52,29 +169,91 @@ function renderTabs(args: Partial) { } // STORIES -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { parameters: { layout: 'fullscreen', + docs: { + description: { + story: 'Use the **Variant** control above to switch between panels variant (default) and navigation variant. The component automatically detects the variant based on whether tab items contain anchor links.\n\n**Note**: The `content` CSS Shadow Part is only available in panels mode, while the `tabs` part is available in both modes.', + }, + }, + }, +}; + +export const PanelsVariant: Story = { + parameters: { + docs: { + description: { + story: 'Panels variant displays tabbed content sections. Each tab shows its associated panel when clicked. Use this for organizing content on the same page.', + }, + }, + }, + args: { + variant: 'panels', + }, +}; + +export const NavigationVariant: Story = { + parameters: { + layout: 'fullscreen', + docs: { + description: { + story: 'Navigation variant is for page navigation. When tab items contain `` elements, the component renders as semantic navigation. Perfect for sub-navigation menus.', + }, + }, + }, + args: { + variant: 'navigation', }, }; -export const ActivePanel: Story = { +export const ActiveTab: Story = { + parameters: { + docs: { + description: { + story: 'Set which tab is initially active using the `active-tab` property. Works in both variants.', + }, + }, + }, args: { - activePanel: 'third', + variant: 'panels', + activeTab: 'third', }, }; export const FullWidth: Story = { parameters: { layout: 'fullscreen', + docs: { + description: { + story: 'Full-width mode stretches the tabs container across the full screen width while keeping content aligned. Available in both modes.', + }, + }, + }, + args: { + variant: 'panels', + fullWidth: true }, - args: { fullWidth: true }, decorators: [story => html`
${story()}
`], }; export const Async: Story = { + parameters: { + docs: { + description: { + story: 'Tabs can be dynamically added or removed. This example shows panels mode with dynamic tab management.', + }, + }, + }, + args: { + variant: 'panels', + }, decorators: [ story => { let tabIndex = 0; @@ -83,24 +262,27 @@ 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); }; const removeActiveTab = () => { - const headers: NodeListOf | undefined = - document.querySelectorAll('post-tab-header'); + const items: NodeListOf | undefined = + document.querySelectorAll('post-tab-item'); - const activeHeader: HTMLPostTabHeaderElement | undefined = Array.from(headers ?? []).find( - () => document.querySelectorAll('post-tab-header.active'), + const activeItem: HTMLPostTabItemElement | undefined = Array.from(items ?? []).find( + item => item.classList.contains('active'), ); - activeHeader?.remove(); + + if (!activeItem) return; const activePanel: HTMLPostTabPanelElement | null = - document.querySelector(`post-tab-panel[name=${activeHeader?.panel}]`) ?? null; + document.querySelector(`post-tab-panel[for="${activeItem.name}"]`) ?? null; + + activeItem?.remove(); activePanel?.remove(); }; @@ -126,3 +308,70 @@ export const Async: Story = { }, ], }; + +export const NavigationWithCurrent: Story = { + parameters: { + layout: 'fullscreen', + docs: { + description: { + story: 'Navigation mode with aria-current="page" for detecting active tab.', + }, + }, + }, + render: (args: Partial) => { + return html` + + +
First page + + + Second page + + + Third page + + + `; + }, + args: { + variant: 'navigation', + }, +}; + +export const MixedMode: Story = { + parameters: { + layout: 'fullscreen', + docs: { + description: { + story: 'Mixed mode example that demonstrates error handling when both navigation and panel elements are present.', + }, + }, + }, + render: (args: Partial) => { + return html` + + + First page + + Second tab + Third tab + + +

This is the content of the second tab.

+
+ +

This is the content of the third tab.

+
+
+ `; + }, + args: { + variant: 'panels', + }, +}; \ No newline at end of file diff --git a/packages/documentation/src/stories/foundations/icons/icon.docs.mdx b/packages/documentation/src/stories/foundations/icons/icon.docs.mdx index 4d1665c218..57c5eff7b4 100644 --- a/packages/documentation/src/stories/foundations/icons/icon.docs.mdx +++ b/packages/documentation/src/stories/foundations/icons/icon.docs.mdx @@ -17,8 +17,8 @@ import './icon.styles.scss';
- Installation - + Installation + ## Installation ### Icon Component @@ -32,8 +32,8 @@ import './icon.styles.scss'; For guidelines on how to set this up, please refer to the [icon package documentation](/?path=/docs/40ed323b-9c1a-42ab-91ed-15f97f214608--docs#usage). - Usage as Web-Component - + Usage as Web-Component + ## Usage as Web-Component Our <post-icon> component renders SVGs, so it scales quickly and easily and can @@ -102,8 +102,8 @@ import './icon.styles.scss'; - Usage as CSS-Background - + Usage as CSS-Background + ## Usage in CSS To define an icon in your own CSS, you need the `@swisspost/design-system-styles` package and use our `post-icon` mixin. The icons are responsive and can have a different level-of-detail, depending on how big they are rendered on the page. @@ -145,8 +145,8 @@ import './icon.styles.scss'; - Find your Icon - + Find your Icon + diff --git a/packages/documentation/src/stories/packages/components/components.docs.mdx b/packages/documentation/src/stories/packages/components/components.docs.mdx index c93158cad3..d3fb65a1e6 100644 --- a/packages/documentation/src/stories/packages/components/components.docs.mdx +++ b/packages/documentation/src/stories/packages/components/components.docs.mdx @@ -38,8 +38,8 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; There are different ways how to consume our web-components, and it depends on your project setup, which one is best suited. - Component Library - + Component Library + ### The Library approach Best, if you don't want to handle lazy-loading yourself. @@ -72,13 +72,13 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; You can also get our component library from a CDN. See the examples below to know how to set it up. - jsDelivr - + jsDelivr + - unpkg - + unpkg + @@ -88,8 +88,8 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; - Standalone Components - + Standalone Components + ### The Standalone approach {/* stencil dist-custom-elements output-target */} @@ -118,13 +118,13 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; - jsDelivr - + jsDelivr + - unpkg - + unpkg + diff --git a/packages/documentation/src/stories/packages/icons/package-icons.mdx b/packages/documentation/src/stories/packages/icons/package-icons.mdx index 7a6c8ce3de..7ca344ea02 100644 --- a/packages/documentation/src/stories/packages/icons/package-icons.mdx +++ b/packages/documentation/src/stories/packages/icons/package-icons.mdx @@ -46,13 +46,13 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; If you want to know more about pre & post scripts and how they are handled, please read the npm documentation. - NodeJS version 16.7.0 or newer - + NodeJS version 16.7.0 or newer + - Older NodeJS versions - + Older NodeJS versions + @@ -67,13 +67,13 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; 2. Use the **base-attribute** solution, to configure the `base-path` on every `icon` component. This can also be used to overwrite the global `base-path` for a single icon. - Meta-tag solution (recommended) - + Meta-tag solution (recommended) + - Base-attribute solution - + Base-attribute solution + `} language="html"/> diff --git a/packages/documentation/src/stories/packages/internet-header/internet-header.docs.mdx b/packages/documentation/src/stories/packages/internet-header/internet-header.docs.mdx index 1c43465b9d..3da39aa703 100644 --- a/packages/documentation/src/stories/packages/internet-header/internet-header.docs.mdx +++ b/packages/documentation/src/stories/packages/internet-header/internet-header.docs.mdx @@ -67,8 +67,8 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; - Bare component: if you already manage the lazy-loading or don't need it for any reason, you can use the component without any overhead. - Install with a bundler - + Install with a bundler + All the popular frameworks come with some form of bundler. This makes it easy to use npm packages like the Internet Header as you can import, bundle and deliver the header with your own code. @@ -93,8 +93,8 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx';
- Include from a CDN - + Include from a CDN + If you are not using any bundler or don't want to install from npm, you can load the `internet-header` from your favourite [CDN](https://en.wikipedia.org/wiki/Content_delivery_network).
Make sure to replace `{version}` with the version you want to use or remove `@{version}` to use the latest version. @@ -128,12 +128,12 @@ import PackageDocs from '@/shared/packages-docs-layout.mdx'; This stylesheet allows you to access CSS variables to implement styling relative to the header as needed, but it is completely optional. - Sass Import - + Sass Import + - HTML Import - + HTML Import + `} dark={SourceDarkScheme} language="html" /> diff --git a/packages/nextjs-integration/package.json b/packages/nextjs-integration/package.json index 043f4d44fe..219a2515c1 100644 --- a/packages/nextjs-integration/package.json +++ b/packages/nextjs-integration/package.json @@ -32,6 +32,7 @@ "react-dom": "19.1.1" }, "devDependencies": { + "@axe-core/playwright": "4.10.2", "@eslint/js": "9.18.0", "@next/eslint-plugin-next": "15.1.5", "@playwright/test": "1.55.0", diff --git a/packages/nextjs-integration/playwright/tests/post-tabs.spec.ts b/packages/nextjs-integration/playwright/tests/post-tabs.spec.ts new file mode 100644 index 0000000000..e8b8c83a88 --- /dev/null +++ b/packages/nextjs-integration/playwright/tests/post-tabs.spec.ts @@ -0,0 +1,141 @@ +import { test, expect, Locator } from '@playwright/test'; +import { PostTabs } from '@swisspost/design-system-components/dist/components/react/post-tabs.js'; +import AxeBuilder from '@axe-core/playwright'; + +test.describe('tabs', () => { + let tabs: Locator; + let tabItems: Locator; + + test.beforeEach(async ({ page }) => { + await page.goto('/ssr'); + + tabs = page.locator('post-tabs[data-hydrated]'); + tabItems = tabs.locator('post-tab-item'); + }); + + test('should render the tabs component', async () => { + await expect(tabs).toHaveCount(1); + }); + + test('should show three tab headers', async () => { + await expect(tabItems).toHaveCount(3); + }); + + test('should only show the first tab header as active', async () => { + await expect(tabItems.first()).toHaveClass(/active/); + await expect(tabItems.nth(1)).not.toHaveClass(/active/); + await expect(tabItems.nth(2)).not.toHaveClass(/active/); + }); + + test('should only show the tab panel associated with the first tab header', async ({ page }) => { + const visiblePanels = page.locator('post-tab-panel:visible'); + await expect(visiblePanels).toHaveCount(1); + + const firstTabName = await tabItems.first().getAttribute('name'); + const visiblePanelFor = await visiblePanels.first().getAttribute('for'); + expect(visiblePanelFor).toBe(firstTabName); + }); + + test('should activate a clicked tab header and deactivate the tab header that was previously activated', async () => { + await tabItems.last().click(); + + await expect(tabItems.first()).not.toHaveClass(/active/); + await expect(tabItems.last()).toHaveClass(/active/); + }); + + test('should show the panel associated with a clicked tab header', async ({ page }) => { + const lastTabName = await tabItems.last().getAttribute('name'); + + await tabItems.last().click(); + + // Wait for the correct panel to become visible + const expectedPanel = page.locator(`post-tab-panel[for="${lastTabName}"]:visible`); + await expect(expectedPanel).toBeVisible(); + + // Verify only one panel is visible + const visiblePanels = page.locator('post-tab-panel:visible'); + await expect(visiblePanels).toHaveCount(1); + }); + + test('should have proper ARIA attributes', async () => { + const tablist = tabs.locator('[role="tablist"]'); + await expect(tablist).toHaveCount(1); + + await expect(tabItems.first()).toHaveAttribute('role', 'tab'); + await expect(tabItems.first()).toHaveAttribute('aria-selected', 'true'); + + await expect(tabItems.nth(1)).toHaveAttribute('aria-selected', 'false'); + await expect(tabItems.nth(2)).toHaveAttribute('aria-selected', 'false'); + }); + + test('should link tabs to panels with aria-controls and aria-labelledby', async ({ page }) => { + const firstTab = tabItems.first(); + const tabId = await firstTab.getAttribute('id'); + const ariaControls = await firstTab.getAttribute('aria-controls'); + + expect(tabId).toBeTruthy(); + expect(ariaControls).toBeTruthy(); + + const associatedPanel = page.locator(`post-tab-panel[id="${ariaControls}"]`); + await expect(associatedPanel).toHaveAttribute('aria-labelledby', tabId!); + }); + + test('should manage tabindex properly', async () => { + await expect(tabItems.first()).toHaveAttribute('tabindex', '0'); + await expect(tabItems.nth(1)).toHaveAttribute('tabindex', '-1'); + await expect(tabItems.nth(2)).toHaveAttribute('tabindex', '-1'); + + await tabItems.last().click(); + + await expect(tabItems.last()).toHaveAttribute('tabindex', '0'); + await expect(tabItems.first()).toHaveAttribute('tabindex', '-1'); + await expect(tabItems.nth(1)).toHaveAttribute('tabindex', '-1'); + }); + + test('should support programmatic tab activation via show() method', async ({ page }) => { + const tabsEl = await tabs.elementHandle(); + + if (tabsEl) { + const secondTabName = await tabItems.nth(1).getAttribute('name'); + + await tabsEl.evaluate( + (el, tabName) => { + (el as PostTabs).show(tabName as string); + }, + secondTabName, + ); + + await expect(tabItems.nth(1)).toHaveClass(/active/); + await expect(tabItems.nth(1)).toHaveAttribute('aria-selected', 'true'); + } + }); + + test('should activate tab on Enter key press', async () => { + await expect(tabItems.nth(1)).toBeVisible(); + + await tabItems.nth(1).focus(); + await tabItems.nth(1).press('Enter'); + + await expect(tabItems.nth(1)).toHaveClass(/active/); + await expect(tabItems.first()).not.toHaveClass(/active/); + }); + + test('should activate tab on Space key press', async () => { + await tabItems.nth(2).focus(); + await tabItems.nth(2).press(' '); + + await expect(tabItems.nth(2)).toHaveClass(/active/); + await expect(tabItems.first()).not.toHaveClass(/active/); + }); + + test('should not have any automatically detectable accessibility issues', async ({ page }) => { + await expect(tabs).toBeVisible(); + await expect(tabItems.first()).toBeVisible(); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .include('post-tabs[data-hydrated]') + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); +}); diff --git a/packages/nextjs-integration/src/app/ssr/page.tsx b/packages/nextjs-integration/src/app/ssr/page.tsx index 0d3c6f80a2..86c782fc46 100644 --- a/packages/nextjs-integration/src/app/ssr/page.tsx +++ b/packages/nextjs-integration/src/app/ssr/page.tsx @@ -15,7 +15,7 @@ import { PostPopover, PostRating, PostTabs, - PostTabHeader, + PostTabItem, PostTabPanel, PostTooltipTrigger, PostTooltip, @@ -153,17 +153,17 @@ export default function Home() {

Tabs

- Unua langeto - Dua langeto - Tria langeto + Unua langeto + Dua langeto + Tria langeto - + Jen la enhavo de la unua langeto. Defaŭlte ĝi montriĝas komence. - + Jen la enhavo de la dua langeto. Defaŭlte ĝi estas kaŝita komence. - + Jen la enhavo de la tria langeto. Defaŭlte ĝi ankaŭ estas kaŝita komence. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2dff89e73..53be9a74e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: version: 14.3.2 cypress-axe: specifier: 1.6.0 - version: 1.6.0(axe-core@4.7.0)(cypress@14.3.2) + version: 1.6.0(axe-core@4.10.3)(cypress@14.3.2) cypress-storybook: specifier: 1.0.0 version: 1.0.0(cypress@14.3.2) @@ -249,6 +249,9 @@ importers: cypress: specifier: 14.3.2 version: 14.3.2 + cypress-axe: + specifier: 1.5.0 + version: 1.5.0(axe-core@4.10.3)(cypress@14.3.2) eslint: specifier: 9.18.0 version: 9.18.0(jiti@2.4.2) @@ -428,7 +431,7 @@ importers: version: 14.3.2 cypress-axe: specifier: 1.6.0 - version: 1.6.0(axe-core@4.7.0)(cypress@14.3.2) + version: 1.6.0(axe-core@4.10.3)(cypress@14.3.2) eslint: specifier: 9.18.0 version: 9.18.0(jiti@2.4.2) @@ -786,6 +789,9 @@ importers: specifier: 19.1.1 version: 19.1.1(react@19.1.1) devDependencies: + '@axe-core/playwright': + specifier: 4.10.2 + version: 4.10.2(playwright-core@1.55.0) '@eslint/js': specifier: 9.18.0 version: 9.18.0 @@ -1019,7 +1025,7 @@ importers: version: 16.12.0(typescript@5.8.3) stylelint-config-sass-guidelines: specifier: 11.1.0 - version: 11.1.0(postcss@8.5.6)(stylelint@16.12.0(typescript@5.8.3)) + version: 11.1.0(postcss@8.5.2)(stylelint@16.12.0(typescript@5.8.3)) stylelint-prettier: specifier: 5.0.3 version: 5.0.3(prettier@3.6.2)(stylelint@16.12.0(typescript@5.8.3)) @@ -1557,6 +1563,11 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@axe-core/playwright@4.10.2': + resolution: {integrity: sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -5964,8 +5975,8 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - axe-core@4.7.0: - resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} + axe-core@4.10.3: + resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} axios@1.11.0: @@ -6713,6 +6724,13 @@ packages: custom-event@1.0.1: resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==} + cypress-axe@1.5.0: + resolution: {integrity: sha512-Hy/owCjfj+25KMsecvDgo4fC/781ccL+e8p+UUYoadGVM2ogZF9XIKbiM6KI8Y3cEaSreymdD6ZzccbI2bY0lQ==} + engines: {node: '>=10'} + peerDependencies: + axe-core: ^3 || ^4 + cypress: ^10 || ^11 || ^12 || ^13 + cypress-axe@1.6.0: resolution: {integrity: sha512-C/ij50G8eebBrl/WsGT7E+T/SFyIsRZ3Epx9cRTLrPL9Y1GcxlQGFoAVdtSFWRrHSCWXq9HC6iJQMaI89O9yvQ==} engines: {node: '>=10'} @@ -13806,6 +13824,11 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@axe-core/playwright@4.10.2(playwright-core@1.55.0)': + dependencies: + axe-core: 4.10.3 + playwright-core: 1.55.0 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -18549,7 +18572,7 @@ snapshots: aws4@1.13.2: {} - axe-core@4.7.0: {} + axe-core@4.10.3: {} axios@1.11.0(debug@4.4.1): dependencies: @@ -19478,9 +19501,14 @@ snapshots: custom-event@1.0.1: {} - cypress-axe@1.6.0(axe-core@4.7.0)(cypress@14.3.2): + cypress-axe@1.5.0(axe-core@4.10.3)(cypress@14.3.2): dependencies: - axe-core: 4.7.0 + axe-core: 4.10.3 + cypress: 14.3.2 + + cypress-axe@1.6.0(axe-core@4.10.3)(cypress@14.3.2): + dependencies: + axe-core: 4.10.3 cypress: 14.3.2 cypress-each@1.14.0: {} @@ -24385,6 +24413,10 @@ snapshots: dependencies: postcss: 8.5.6 + postcss-scss@4.0.9(postcss@8.5.2): + dependencies: + postcss: 8.5.2 + postcss-scss@4.0.9(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -25981,6 +26013,13 @@ snapshots: postcss: 8.5.6 postcss-selector-parser: 6.1.2 + stylelint-config-sass-guidelines@11.1.0(postcss@8.5.2)(stylelint@16.12.0(typescript@5.8.3)): + dependencies: + postcss: 8.5.2 + postcss-scss: 4.0.9(postcss@8.5.2) + stylelint: 16.12.0(typescript@5.8.3) + stylelint-scss: 6.10.0(stylelint@16.12.0(typescript@5.8.3)) + stylelint-config-sass-guidelines@11.1.0(postcss@8.5.6)(stylelint@16.12.0(typescript@5.8.3)): dependencies: postcss: 8.5.6