From 0e52b2ea331309b532a14a5cee774dee517e1e40 Mon Sep 17 00:00:00 2001 From: Caine Rotherham Date: Wed, 30 Oct 2024 13:52:15 +0100 Subject: [PATCH] feat: Add a11y tab component to plp facets (#19244) Closes: https://jira.tools.sap/browse/CXSPA-2581 --- .../assets/src/translations/en/product.json | 3 +- .../keyboard-navigation.e2e.cy.ts | 40 +++-- .../product-search.core-e2e.cy.ts | 7 + .../cypress/helpers/product-search.ts | 13 +- .../tab/panel/tab-panel.component.html | 9 +- .../tab/panel/tab-panel.component.spec.ts | 11 ++ .../content/tab/tab.component.spec.ts | 140 +++++++++++++++++- .../content/tab/tab.component.ts | 89 +++++++++-- .../cms-components/content/tab/tab.model.ts | 12 +- .../facet-list/facet-list.component.html | 57 ++++--- .../facet-list/facet-list.component.spec.ts | 48 +++--- .../facet-list/facet-list.component.ts | 58 +++++++- .../facet-list/facet-list.module.ts | 2 + .../facet/facet.component.html | 3 +- .../facet/facet.component.spec.ts | 26 +--- .../facet/facet.component.ts | 31 ++++ .../facet/facet.module.ts | 3 +- .../scss/components/content/tab/_tab.scss | 1 + .../scss/components/product/list/_facet.scss | 4 + 19 files changed, 432 insertions(+), 125 deletions(-) diff --git a/projects/assets/src/translations/en/product.json b/projects/assets/src/translations/en/product.json index 256b33ad25fa..a56d1d2c98a5 100644 --- a/projects/assets/src/translations/en/product.json +++ b/projects/assets/src/translations/en/product.json @@ -46,7 +46,8 @@ "ariaLabelItemsAvailable_other": "{{name}}, {{state}} {{count}} items available", "decreaseOptionsVisibility": "Options were hidden from the active group, tab backward to read them or forward for the next group", "increaseOptionsVisibility": "More options were added to the active group, tab backward to read them or forward for the next group", - "backToResults": "Back To Results" + "backToResults": "Back To Results", + "productFacets": "Product Facets" }, "productSummary": { "id": "ID", diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/keyboard-navigation.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/keyboard-navigation.e2e.cy.ts index a5d69eb0f263..0beefe51843b 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/keyboard-navigation.e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/keyboard-navigation.e2e.cy.ts @@ -45,22 +45,10 @@ describe('Kayboard navigation', () => { context('Facet component', () => { beforeEach(() => { cy.visit('/Brands/all/c/brands'); - cy.get('cx-facet button').first().as('facetHeader'); + cy.get('cx-facet-list button.tab-btn').first().as('facetHeader'); cy.get('cx-facet a').first().as('firstFacetOption'); }); - it('focuses first option on ArrowDown while facet header is focused', () => { - cy.get('@facetHeader').focus().type('{downArrow}'); - cy.get('@firstFacetOption').should('have.focus'); - }); - - it('toggles facet closed/open with left and right arrow keys', () => { - cy.get('@facetHeader').focus().type('{leftArrow}'); - cy.get('@firstFacetOption').should('not.be.visible'); - cy.get('@facetHeader').focus().type('{rightArrow}'); - cy.get('@firstFacetOption').should('be.visible'); - }); - it('navigates facet options with down arrow key', () => { cy.get('@firstFacetOption').focus().type('{downArrow}'); cy.contains('Choshi').should('have.focus').type('{downArrow}'); @@ -86,5 +74,31 @@ describe('Kayboard navigation', () => { cy.contains('Chiba').should('have.focus').type('{upArrow}'); cy.contains('Chiba').should('have.focus'); }); + + it('navigates facet categories with down arrow key', () => { + cy.get('cx-facet-list').get('@facetHeader').focus().type('{downArrow}'); + cy.get('cx-facet-list') + .contains('Price') + .should('have.focus') + .type('{downArrow}'); + cy.get('cx-facet-list') + .contains('Resolution') + .should('have.focus') + .type('{downArrow}'); + cy.get('cx-facet-list').contains('Mounting').should('have.focus'); + }); + + it('navigates facet categories with up arrow key', () => { + cy.get('@facetHeader').focus().type('{upArrow}'); + cy.get('cx-facet-list') + .contains('Category') + .should('have.focus') + .type('{upArrow}'); + cy.get('cx-facet-list') + .contains('Brand') + .should('have.focus') + .type('{upArrow}'); + cy.get('cx-facet-list').contains('Color').should('have.focus'); + }); }); }); diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product-search/product-search.core-e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product-search/product-search.core-e2e.cy.ts index 054570036662..ba03154d767b 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product-search/product-search.core-e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product-search/product-search.core-e2e.cy.ts @@ -12,6 +12,13 @@ context('Product search', { testIsolation: false }, () => { viewportContext(['mobile', 'desktop'], () => { isolateTests(); before(() => { + // TODO: No longer needed to toggle a11yTabComponent feature when set to true + // by default. + cy.cxConfig({ + features: { + a11yTabComponent: true, + }, + }); cy.visit('/'); }); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts index 86c6bbc4de9f..4d7545621f69 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-search.ts @@ -231,7 +231,7 @@ export function clickFacet(header: string) { cy.onMobile(() => { cy.get('cx-product-facet-navigation button').click(); }); - cy.get('cx-facet .heading') + cy.get('cx-facet-list cx-tab button.tab-btn') .contains(header) .then((el) => { if (el.find('.fa-plus').is(':visible')) { @@ -239,13 +239,10 @@ export function clickFacet(header: string) { cy.wrap(el).click({ force: true }); } }); - cy.get('cx-facet .heading') - .contains(header) - .parents('cx-facet') - .within(() => { - // TODO Remove force once you can scroll facets on mobile - cy.get('a.value').first().click({ force: true }); - }); + cy.get(`cx-facet[aria-label^="${header}"]`).within(() => { + // TODO Remove force once you can scroll facets on mobile + cy.get('a.value').first().click({ force: true }); + }); cy.onMobile(() => { cy.get('cx-product-facet-navigation button.close').click(); }); diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html index 33e81bd180b4..c13b924551e6 100644 --- a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html @@ -1,10 +1,13 @@
- +
diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts index b64b8dd0e695..803d82ea6e9e 100644 --- a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts @@ -58,6 +58,17 @@ describe('TabPanelComponent', () => { expect(tabPanel?.getAttribute('aria-labelledby')).toEqual('1'); }); + it('should have correct attribues when disableBorderFocus is active', () => { + component.tab = { + ...mockTab, + disableBorderFocus: true, + }; + fixture.detectChanges(); + + const tabPanel = document.querySelector('div[role="tabpanel"]'); + expect(tabPanel?.getAttribute('tabindex')).toEqual(null); + }); + it('should display template ref', () => { const mockFixture = TestBed.createComponent(MockComponent); mockFixture.detectChanges(); diff --git a/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts b/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts index 4b8b3ca56fc2..8e3bce3cf266 100644 --- a/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts +++ b/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { I18nTestingModule } from '@spartacus/core'; +import { of } from 'rxjs'; import { TabComponent } from './tab.component'; import { TAB_MODE } from './tab.model'; @@ -10,11 +11,9 @@ describe('TabComponent', () => { const mockTabs = [ { headerKey: 'tab0', - header: 'tab 0', id: 0, }, { - headerKey: 'tab1', header: 'tab 1', id: 1, }, @@ -30,6 +29,8 @@ describe('TabComponent', () => { }, ]; + const mockTabs$ = of(mockTabs); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [I18nTestingModule], @@ -77,6 +78,34 @@ describe('TabComponent', () => { expect(secondButton.getAttribute('tabindex')).toEqual('-1'); }); + it('should display menu buttons for tabs$', () => { + component.tabs = []; + component.tabs$ = mockTabs$; + fixture.detectChanges(); + + const tabEl = document.querySelector('div[class="tab"]'); + expect(tabEl?.role).toEqual('tablist'); + + const buttonEls = document.querySelectorAll('button[role="tab"]'); + expect(buttonEls.length).toEqual(4); + + const firstButton = buttonEls[0]; + expect(firstButton.getAttribute('id')).toEqual('0'); + expect(firstButton.getAttribute('class')).toEqual('tab-btn active'); + expect(firstButton.getAttribute('aria-selected')).toEqual('true'); + expect(firstButton.getAttribute('aria-expanded')).toEqual(null); + expect(firstButton.getAttribute('aria-controls')).toEqual('section-0'); + expect(firstButton.getAttribute('tabindex')).toEqual('0'); + + const secondButton = buttonEls[1]; + expect(secondButton.getAttribute('id')).toEqual('1'); + expect(secondButton.getAttribute('class')).toEqual('tab-btn'); + expect(secondButton.getAttribute('aria-selected')).toEqual('false'); + expect(secondButton.getAttribute('aria-expanded')).toEqual(null); + expect(secondButton.getAttribute('aria-controls')).toEqual('section-1'); + expect(secondButton.getAttribute('tabindex')).toEqual('-1'); + }); + it('should navigate menu buttons with arrow keys', () => { expect(component.isOpen(0)).toEqual(true); expect(component.isOpen(1)).toEqual(false); @@ -172,6 +201,64 @@ describe('TabComponent', () => { expect(component.isOpen(3)).toEqual(false); }); + it('should NOT navigate menu buttons with restricted arrow keys', () => { + component.config = { + label: 'test', + mode: TAB_MODE.TAB, + openTabs: [0], + restrictDirectionKeys: true, + }; + fixture.detectChanges(); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowDown' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + ); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowUp' }) + ); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + }); + it('should navigate to last tab with END key', () => { expect(component.isOpen(0)).toEqual(true); expect(component.isOpen(1)).toEqual(false); @@ -259,6 +346,7 @@ describe('TabComponent', () => { expect(firstButton.getAttribute('aria-expanded')).toEqual('true'); expect(firstButton.getAttribute('aria-controls')).toEqual('section-0'); expect(firstButton.getAttribute('tabindex')).toEqual('0'); + expect(firstButton.getAttribute('title')).toEqual('Collapse tab0'); const secondButton = buttonEls[1]; expect(secondButton.getAttribute('id')).toEqual('1'); @@ -267,6 +355,7 @@ describe('TabComponent', () => { expect(secondButton.getAttribute('aria-expanded')).toEqual('false'); expect(secondButton.getAttribute('aria-controls')).toEqual('section-1'); expect(secondButton.getAttribute('tabindex')).toEqual('0'); + expect(secondButton.getAttribute('title')).toEqual('Expand tab 1'); }); it('should toggle tabs correctly in accordian mode', () => { @@ -294,5 +383,52 @@ describe('TabComponent', () => { expect(component.isOpen(1)).toEqual(false); expect(component.isOpen(2)).toEqual(true); }); + + it('should NOT navigate menu buttons with restricted arrow keys', () => { + const spy = spyOn(component, 'selectOrFocus'); + component.config = { + label: 'test', + mode: TAB_MODE.ACCORDIAN, + openTabs: [0], + restrictDirectionKeys: true, + }; + fixture.detectChanges(); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + + expect(spy).toHaveBeenCalledTimes(0); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowDown' }) + ); + + expect(spy).toHaveBeenCalledTimes(1); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + ); + + expect(spy).toHaveBeenCalledTimes(1); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowUp' }) + ); + + expect(spy).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/projects/storefrontlib/cms-components/content/tab/tab.component.ts b/projects/storefrontlib/cms-components/content/tab/tab.component.ts index 93dc75b5591a..bf184bdadbbd 100644 --- a/projects/storefrontlib/cms-components/content/tab/tab.component.ts +++ b/projects/storefrontlib/cms-components/content/tab/tab.component.ts @@ -5,16 +5,20 @@ */ import { + AfterViewInit, ChangeDetectionStrategy, + ChangeDetectorRef, Component, + inject, Input, + OnDestroy, OnInit, QueryList, ViewChildren, } from '@angular/core'; import { BreakpointService } from '../../../layout/breakpoint'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, Observable, of, Subscription } from 'rxjs'; +import { map, take } from 'rxjs/operators'; import { Tab, TabConfig, TAB_MODE } from './tab.model'; import { wrapIntoBounds } from './tab.utils'; import { TranslationService } from '@spartacus/core'; @@ -24,27 +28,53 @@ import { TranslationService } from '@spartacus/core'; templateUrl: './tab.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TabComponent implements OnInit { +export class TabComponent implements OnInit, AfterViewInit, OnDestroy { + /** + * If you have nested templates that are subject to complex changes, + * it can be better to use this property instead with an Observable + * to set Tabs. + * + * Note: You should NOT set the `tabs` property if using this. + */ + @Input() tabs$: Observable; @Input() tabs: Tab[] | any; @Input() config: TabConfig | any; readonly TAB_MODE = TAB_MODE; - openTabs$: BehaviorSubject; - mode$: Observable; + protected breakpointService = inject(BreakpointService); + protected translationService = inject(TranslationService); + protected cd = inject(ChangeDetectorRef); @ViewChildren('tabHeader') tabHeaders: QueryList; - constructor( - protected breakpointService: BreakpointService, - protected translationService: TranslationService - ) {} + openTabs$: BehaviorSubject; + mode$: Observable; + protected subscriptions = new Subscription(); ngOnInit(): void { this.openTabs$ = new BehaviorSubject(this.config?.openTabs ?? []); this.mode$ = this.getMode(); } + ngAfterViewInit(): void { + /** + * We subscribe to the tabs observable if added and use this to set + * the `tabs` property. The input `tabs` property should not be + * initialized. It will be overwritten by this otherwise. + */ + this.subscriptions.add( + this.tabs$?.subscribe((tabs) => { + this.tabs = tabs; + this.cd.detectChanges(); + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + /** * Tab selection works differently depending on the given mode. * @@ -104,6 +134,14 @@ export class TabComponent implements OnInit { const PREVIOUS_TAB = wrapIntoBounds(tabNum - 1, LAST_TAB); const NEXT_TAB = wrapIntoBounds(tabNum + 1, LAST_TAB); + // Disable some keys is `restrictDirectionKeys` is enabled. + if ( + this.config.restrictDirectionKeys && + this.shouldRestrictKeys(mode, event) + ) { + return; + } + switch (event.key) { case 'ArrowLeft': case 'ArrowUp': @@ -118,6 +156,21 @@ export class TabComponent implements OnInit { } } + protected shouldRestrictKeys(mode: TAB_MODE, event: KeyboardEvent): boolean { + if (mode === TAB_MODE.TAB && ['ArrowUp', 'ArrowDown'].includes(event.key)) { + return true; + } + + if ( + mode === TAB_MODE.ACCORDIAN && + ['ArrowLeft', 'ArrowRight'].includes(event.key) + ) { + return true; + } + + return false; + } + /** * Indicates whether a tab is open (in the open tabs array). */ @@ -163,15 +216,19 @@ export class TabComponent implements OnInit { return null; } + let header = tab.header; + if (tab.headerKey) { + this.translationService + .translate(tab.headerKey) + .pipe(take(1)) + .subscribe((val) => { + header = val; + }); + } + return ( // Show expanded or collapsed. - (this.isOpen(index) ? 'Collapse' : 'Expand') + - ' ' + - // Show the translation key for header if available. - // Otherwise fallback to header string value. - (tab.headerKey - ? this.translationService.translate(tab.headerKey) - : tab.header) + (this.isOpen(index) ? 'Collapse' : 'Expand') + ' ' + header ); } diff --git a/projects/storefrontlib/cms-components/content/tab/tab.model.ts b/projects/storefrontlib/cms-components/content/tab/tab.model.ts index ccd82719e202..81a3d6a4937e 100644 --- a/projects/storefrontlib/cms-components/content/tab/tab.model.ts +++ b/projects/storefrontlib/cms-components/content/tab/tab.model.ts @@ -20,11 +20,16 @@ export interface Tab { /** * Content to display in tab panel when open. */ - content: TemplateRef; + content?: TemplateRef; /** * Identifies the index of the tab to set attributes by. */ id?: number; + /** + * Disables the tabindex on the border element so that the border + * of the tab can no longer be focused. + */ + disableBorderFocus?: boolean; } export interface TabConfig { @@ -46,6 +51,11 @@ export interface TabConfig { * The indexes of tabs to have open initially. */ openTabs?: number[]; + /** + * Restricts the direction keys that can be used to navigate between tabs. + * When enabled, tab mode can only use left/right arrow keys and accordian mode up/down. + */ + restrictDirectionKeys?: boolean; } export enum TAB_MODE { diff --git a/projects/storefrontlib/cms-components/product/product-list/product-facet-navigation/facet-list/facet-list.component.html b/projects/storefrontlib/cms-components/product/product-list/product-facet-navigation/facet-list/facet-list.component.html index d53dcfeef283..23e3fc1a8256 100644 --- a/projects/storefrontlib/cms-components/product/product-list/product-facet-navigation/facet-list/facet-list.component.html +++ b/projects/storefrontlib/cms-components/product/product-list/product-facet-navigation/facet-list/facet-list.component.html @@ -8,9 +8,7 @@ (click)="block($event)" >
-

- {{ 'productList.filterBy.label' | cxTranslate }} -

+

{{ 'productList.filterBy.label' | cxTranslate }}