diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/aux-key.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/aux-key.e2e.cy.ts index 62378bce480..49b981c33c7 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/aux-key.e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/aux-key.e2e.cy.ts @@ -42,6 +42,11 @@ context('Auxiliary Keys', () => { code: 'Space', force: true, }); + cy.focused().trigger('keydown', { + key: ' ', + code: 'Space', + force: true, + }); cy.get('div.wrapper') .should('have.length', 7) .first() 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 new file mode 100644 index 00000000000..8280197c421 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/keyboard-navigation.e2e.cy.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('Kayboard navigation', () => { + context('Navigation UI component', () => { + beforeEach(() => { + cy.visit('/'); + cy.get('cx-navigation-ui [aria-haspopup="true"]') + .first() + .as('firstDropdown'); + }); + it('opens/closes a node after pressing space', () => { + cy.get('@firstDropdown').focus().type(' ').next().should('be.visible'); + cy.focused().type(' '); + cy.focused().type(' ').should('not.be.visible'); + }); + + it('navigates node children with downArrow key', () => { + cy.get('@firstDropdown').focus().type(' '); + cy.contains(' Cameras ').type('{downArrow}'); + cy.contains(' Canon ').should('have.focus').type('{downArrow}'); + cy.contains(' Sony ').should('have.focus').type('{downArrow}'); + cy.contains(' Kodak ').should('have.focus').type('{downArrow}'); + cy.contains(' Samsung ').should('have.focus').type('{downArrow}'); + cy.contains(' Toshiba ').should('have.focus').type('{downArrow}'); + cy.contains(' Fujifilm ').should('have.focus').type('{downArrow}'); + cy.contains(' Fujifilm ').should('have.focus'); + }); + + it('navigates node children with upArrow key', () => { + cy.get('@firstDropdown').focus().type(' '); + cy.contains(' Fujifilm ').focus().type('{upArrow}'); + cy.contains(' Toshiba ').should('have.focus').type('{upArrow}'); + cy.contains(' Samsung ').should('have.focus').type('{upArrow}'); + cy.contains(' Kodak ').should('have.focus').type('{upArrow}'); + cy.contains(' Sony ').should('have.focus').type('{upArrow}'); + cy.contains(' Canon ').should('have.focus').type('{upArrow}'); + cy.contains(' Canon ').should('have.focus'); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts index fbff46dbf87..f7ed4a63e62 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts @@ -177,11 +177,11 @@ export function agentLogin(user, pwd): void { cy.get('cx-csagent-login-form form').within(() => { cy.get('[formcontrolname="userId"]') .clear() - .type(user) + .type(user, { force: true }) .should('have.value', user); cy.get('[formcontrolname="password"]') .clear() - .type(pwd) + .type(pwd, { force: true }) .should('have.value', pwd); cy.get('button[type="submit"]').click(); }); diff --git a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html index b288de7711f..c208cee1a5f 100644 --- a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html +++ b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html @@ -20,19 +20,39 @@
  • - - {{ node.title }} - + + + {{ node.title }} + + + + + {{ node.title }} + + @@ -44,21 +64,41 @@ > {{ node.title }} - + + + + + + diff --git a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts index 148877a862e..63563815819 100644 --- a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts +++ b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts @@ -2,7 +2,12 @@ import { Component, DebugElement, ElementRef, Input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { I18nTestingModule, WindowRef } from '@spartacus/core'; +import { + FeatureConfigService, + I18nTestingModule, + WindowRef, +} from '@spartacus/core'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; import { of } from 'rxjs'; import { HamburgerMenuService } from './../../../layout/header/hamburger-menu/hamburger-menu.service'; import { NavigationNode } from './navigation-node.model'; @@ -18,7 +23,7 @@ class MockIconComponent { @Component({ selector: 'cx-generic-link', - template: '{{title}}', + template: '{{title}}', }) class MockGenericLinkComponent { @Input() url: string | any[]; @@ -31,6 +36,13 @@ class MockHamburgerMenuService { toggle(_forceCollapse?: boolean): void {} } +// TODO: (CXSPA-5919) Remove mock next major release +class MockFeatureConfigService { + isEnabled() { + return true; + } +} + const mockWinRef: WindowRef = { nativeWindow: { location: { href: '/sub-sub-child-1a' }, @@ -98,6 +110,8 @@ describe('Navigation UI Component', () => { NavigationUIComponent, MockIconComponent, MockGenericLinkComponent, + // TODO: (CXSPA-5919) Remove feature directive next major + MockFeatureDirective, ], providers: [ { @@ -108,6 +122,10 @@ describe('Navigation UI Component', () => { provide: WindowRef, useValue: mockWinRef, }, + { + provide: FeatureConfigService, + useClass: MockFeatureConfigService, + }, ], }).compileComponents(); }); @@ -306,4 +324,66 @@ describe('Navigation UI Component', () => { expect(hamburgerMenuService.toggle).toHaveBeenCalledWith(); }); }); + describe('Keyboard navigation', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should toggle open when space key is pressed', () => { + const spy = spyOn(navigationComponent, 'toggleOpen'); + const spaceEvent = new KeyboardEvent('keydown', { code: 'Space' }); + const dropDownButton = element.query( + By.css('button[aria-label="Sub child 1"]') + ).nativeElement; + Object.defineProperty(spaceEvent, 'target', { value: dropDownButton }); + + navigationComponent.onSpace(spaceEvent); + + expect(spy).toHaveBeenCalled(); + }); + + it('should move focus to the opened node', () => { + const firstChild = element.query(By.css('[href="/sub-sub-child-1a"]')); + const spy = spyOn(firstChild.nativeElement, 'focus'); + const spaceEvent = new KeyboardEvent('keydown', { code: 'Space' }); + const dropDownButton = element.query( + By.css('button[aria-label="Sub child 1"]') + ).nativeElement; + Object.defineProperty(spaceEvent, 'target', { value: dropDownButton }); + + navigationComponent.focusOnNode(spaceEvent); + + expect(spy).toHaveBeenCalled(); + }); + + it('should move focus inside node on up/down arrow press', () => { + navigationComponent.toggleOpen = () => {}; + const firstChild = element.query(By.css('[href="/sub-sub-child-1a"]')); + const secondChild = element.query(By.css('[href="/sub-sub-child-1b"]')); + const arrowDownEvent = new KeyboardEvent('keydown', { + code: 'ArrowDown', + }); + const arrowUpEvent = new KeyboardEvent('keydown', { + code: 'ArrowUp', + }); + const spaceEvent = new KeyboardEvent('keydown', { code: 'Space' }); + const dropDownButton = element.query( + By.css('button[aria-label="Sub child 1"]') + ).nativeElement; + Object.defineProperty(spaceEvent, 'target', { value: dropDownButton }); + Object.defineProperty(arrowDownEvent, 'target', { + value: firstChild.nativeElement, + }); + Object.defineProperty(arrowUpEvent, 'target', { + value: secondChild.nativeElement, + }); + + navigationComponent.onSpace(spaceEvent); + + navigationComponent['arrowControls'].next(arrowDownEvent); + expect(document.activeElement).toEqual(secondChild.nativeElement); + navigationComponent['arrowControls'].next(arrowUpEvent); + expect(document.activeElement).toEqual(firstChild.nativeElement); + }); + }); }); diff --git a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.ts b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.ts index 4782f237dbb..a1f169d3140 100644 --- a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.ts +++ b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.ts @@ -14,12 +14,18 @@ import { Input, OnDestroy, OnInit, + Optional, Renderer2, } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; -import { WindowRef } from '@spartacus/core'; -import { Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; +import { FeatureConfigService, WindowRef } from '@spartacus/core'; +import { Subject, Subscription } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + filter, + take, +} from 'rxjs/operators'; import { ICON_TYPE } from '../../misc/icon/index'; import { HamburgerMenuService } from './../../../layout/header/hamburger-menu/hamburger-menu.service'; import { NavigationNode } from './navigation-node.model'; @@ -64,18 +70,26 @@ export class NavigationUIComponent implements OnInit, OnDestroy { private openNodes: HTMLElement[] = []; private subscriptions = new Subscription(); private resize = new EventEmitter(); + protected arrowControls: Subject = new Subject(); @HostListener('window:resize') onResize() { this.resize.next(undefined); } + @HostListener('document:keyDown.arrowUp', ['$event']) + @HostListener('document:keyDown.arrowDown', ['$event']) + onArrow(e: KeyboardEvent) { + this.arrowControls.next(e); + } + constructor( private router: Router, private renderer: Renderer2, private elemRef: ElementRef, protected hamburgerMenuService: HamburgerMenuService, - protected winRef: WindowRef + protected winRef: WindowRef, + @Optional() protected featureConfigService?: FeatureConfigService ) { this.subscriptions.add( this.router.events @@ -117,13 +131,20 @@ export class NavigationUIComponent implements OnInit, OnDestroy { typeof navNode.url === 'string' && this.winRef.nativeWindow?.location.href.includes(navNode.url) ) { - this.elemRef.nativeElement - .querySelectorAll('li.is-open:not(.back), li.is-opened') - .forEach((el: any) => { - this.renderer.removeClass(el, 'is-open'); - this.renderer.removeClass(el, 'is-opened'); - }); - this.reinitializeMenu(); + // TODO: (CXSPA-5919) Remove feature flag next major release + if ( + this.featureConfigService?.isEnabled('a11yNavigationUiKeyboardControls') + ) { + this.reinitializeMenu(); + } else { + this.elemRef.nativeElement + .querySelectorAll('li.is-open:not(.back), li.is-opened') + .forEach((el: any) => { + this.renderer.removeClass(el, 'is-open'); + this.renderer.removeClass(el, 'is-opened'); + }); + this.reinitializeMenu(); + } this.hamburgerMenuService.toggle(); } } @@ -133,8 +154,25 @@ export class NavigationUIComponent implements OnInit, OnDestroy { */ reinitializeMenu(): void { if (this.openNodes?.length > 0) { + // TODO: (CXSPA-5919) Remove feature flag next major release + if ( + this.featureConfigService?.isEnabled('a11yNavigationUiKeyboardControls') + ) { + this.elemRef.nativeElement + .querySelectorAll('li.is-open:not(.back), li.is-opened') + .forEach((el: any) => { + this.renderer.removeClass(el, 'is-open'); + this.renderer.removeClass(el, 'is-opened'); + }); + } this.clear(); - this.renderer.removeClass(this.elemRef.nativeElement, 'is-open'); + if ( + !this.featureConfigService?.isEnabled( + 'a11yNavigationUiKeyboardControls' + ) + ) { + this.renderer.removeClass(this.elemRef.nativeElement, 'is-open'); + } } } @@ -173,6 +211,54 @@ export class NavigationUIComponent implements OnInit, OnDestroy { event.stopPropagation(); } + /** + * Opens dropdown and starts keyboard navigation + */ + onSpace(event: UIEvent): void { + this.hamburgerMenuService.isExpanded + .pipe(take(1)) + .subscribe((isExpanded) => { + if (isExpanded) { + this.toggleOpen(event); + return; + } + if (!this.openNodes.length) { + this.toggleOpen(event); + return; + } + event.preventDefault(); + }); + this.focusOnNode(event); + this.setupArrowControls(); + } + + /** + * Subscribes to arrow keys and enables navigation between dropdown items + */ + setupArrowControls(): void { + this.subscriptions.add( + this.arrowControls.subscribe((e) => { + e.preventDefault(); + const parentElement = (e.target).parentElement + ?.parentElement; + const nextLink = parentElement?.nextElementSibling?.querySelector('a'); + const previousLink = + parentElement?.previousElementSibling?.querySelector('a'); + e.code === 'ArrowDown' ? nextLink?.focus() : previousLink?.focus(); + }) + ); + } + + /** + * Focuses on the first focusable element in the dropdown + */ + focusOnNode(event: UIEvent): void { + const firstFocusableElement = + (event.target).nextElementSibling?.querySelector('button') || + (event.target).nextElementSibling?.querySelector('a'); + firstFocusableElement?.focus(); + } + back(): void { if (this.openNodes[this.openNodes.length - 1]) { this.renderer.removeClass( @@ -186,7 +272,11 @@ export class NavigationUIComponent implements OnInit, OnDestroy { clear(): void { this.openNodes = []; - this.updateClasses(); + if ( + !this.featureConfigService?.isEnabled('a11yNavigationUiKeyboardControls') + ) { + this.updateClasses(); + } } onMouseEnter(event: MouseEvent) { diff --git a/projects/storefrontlib/cms-components/navigation/navigation/navigation.module.ts b/projects/storefrontlib/cms-components/navigation/navigation/navigation.module.ts index 1a4560f0159..de43d43af66 100644 --- a/projects/storefrontlib/cms-components/navigation/navigation/navigation.module.ts +++ b/projects/storefrontlib/cms-components/navigation/navigation/navigation.module.ts @@ -7,7 +7,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { + CmsConfig, + FeaturesConfigModule, + I18nModule, + provideDefaultConfig, +} from '@spartacus/core'; import { GenericLinkModule } from '../../../shared/components/generic-link/generic-link.module'; import { IconModule } from '../../misc/icon/icon.module'; import { NavigationUIComponent } from './navigation-ui.component'; @@ -20,6 +25,7 @@ import { NavigationComponent } from './navigation.component'; IconModule, GenericLinkModule, I18nModule, + FeaturesConfigModule, ], providers: [ provideDefaultConfig({ diff --git a/projects/storefrontlib/shared/components/generic-link/generic-link.component.html b/projects/storefrontlib/shared/components/generic-link/generic-link.component.html index 64725425dfa..70343e9f3b0 100644 --- a/projects/storefrontlib/shared/components/generic-link/generic-link.component.html +++ b/projects/storefrontlib/shared/components/generic-link/generic-link.component.html @@ -9,6 +9,7 @@ [attr.class]="class" [attr.style]="style" [attr.title]="title" + [tabindex]="tabindex" > @@ -24,6 +25,7 @@ [attr.class]="class" [attr.style]="style" [attr.title]="title" + [tabindex]="tabindex" > diff --git a/projects/storefrontlib/shared/components/generic-link/generic-link.component.ts b/projects/storefrontlib/shared/components/generic-link/generic-link.component.ts index aced4956133..27b903dc389 100644 --- a/projects/storefrontlib/shared/components/generic-link/generic-link.component.ts +++ b/projects/storefrontlib/shared/components/generic-link/generic-link.component.ts @@ -53,6 +53,7 @@ export class GenericLinkComponent implements OnChanges { @Input() class: string; @Input() style: string | undefined; @Input() title: string; + @Input() tabindex: 0 | -1 = 0; isExternalUrl(): boolean { return this.service.isExternalUrl(this.url); diff --git a/projects/storefrontlib/shared/test/mock-feature-directive.ts b/projects/storefrontlib/shared/test/mock-feature-directive.ts index 241846bf679..aa3aebbb1b4 100644 --- a/projects/storefrontlib/shared/test/mock-feature-directive.ts +++ b/projects/storefrontlib/shared/test/mock-feature-directive.ts @@ -16,6 +16,9 @@ export class MockFeatureDirective { ) {} @Input() set cxFeature(_feature: string) { - this.viewContainer.createEmbeddedView(this.templateRef); + // ensure the deprecated DOM changes are not rendered during tests + if (!_feature.toString().includes('!')) { + this.viewContainer.createEmbeddedView(this.templateRef); + } } }