Skip to content

Commit

Permalink
feat: (CXSPA-981) - Add keyboard navigation to 'navigation-ui' (#18297)
Browse files Browse the repository at this point in the history
Co-authored-by: Piotr Bartkowiak <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Mar 22, 2024
1 parent 1c6cffb commit d0c9164
Show file tree
Hide file tree
Showing 10 changed files with 318 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2024 SAP Spartacus team <[email protected]>
*
* 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');
});
});
});
4 changes: 2 additions & 2 deletions projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,39 @@

<ng-template #nav let-node="node" let-depth="depth">
<li>
<cx-generic-link
*ngIf="
node.url && (!node.children || node.children?.length === 0);
else heading
"
[url]="node.url"
[target]="node.target"
[style]="node.styleAttributes"
[class]="node.styleClasses"
(click)="closeIfClickedTheSameLink(node)"
>
{{ node.title }}
</cx-generic-link>
<ng-container *cxFeature="'!a11yNavigationUiKeyboardControls'">
<cx-generic-link
*ngIf="
node.url && (!node.children || node.children?.length === 0);
else heading
"
[url]="node.url"
[target]="node.target"
[style]="node.styleAttributes"
[class]="node.styleClasses"
(click)="closeIfClickedTheSameLink(node)"
>
{{ node.title }}
</cx-generic-link>
</ng-container>
<ng-container *cxFeature="'a11yNavigationUiKeyboardControls'">
<cx-generic-link
*ngIf="
node.url && (!node.children || node.children?.length === 0);
else heading
"
[url]="node.url"
[target]="node.target"
[style]="node.styleAttributes"
[class]="node.styleClasses"
(click)="closeIfClickedTheSameLink(node)"
[tabindex]="depth > 0 && !node.children ? -1 : 0"
(focus)="depth || reinitializeMenu()"
(keydown.space)="toggleOpen($any($event))"
>
{{ node.title }}
</cx-generic-link>
</ng-container>

<ng-template #heading>
<ng-container *ngIf="flyout && node.children?.length > 0; else title">
Expand All @@ -44,21 +64,41 @@
>
{{ node.title }}
</cx-generic-link>
<button
[attr.tabindex]="depth < 1 ? 0 : -1"
[attr.aria-haspopup]="true"
[attr.aria-expanded]="false"
[attr.aria-label]="node.title"
(click)="toggleOpen($any($event))"
(mouseenter)="onMouseEnter($event)"
(keydown.space)="toggleOpen($any($event))"
(keydown.esc)="back()"
>
<ng-container *ngIf="!node.url">
{{ node.title }}
</ng-container>
<cx-icon [type]="iconType.CARET_DOWN"></cx-icon>
</button>
<ng-container *cxFeature="'!a11yNavigationUiKeyboardControls'">
<button
[attr.tabindex]="depth < 1 ? 0 : -1"
[attr.aria-haspopup]="true"
[attr.aria-expanded]="false"
[attr.aria-label]="node.title"
(click)="toggleOpen($any($event))"
(mouseenter)="onMouseEnter($event)"
(keydown.space)="toggleOpen($any($event))"
(keydown.esc)="back()"
>
<ng-container *ngIf="!node.url">
{{ node.title }}
</ng-container>
<cx-icon [type]="iconType.CARET_DOWN"></cx-icon>
</button>
</ng-container>
<ng-container *cxFeature="'a11yNavigationUiKeyboardControls'">
<button
[attr.aria-haspopup]="true"
[attr.aria-expanded]="false"
[attr.aria-label]="node.title"
(click)="toggleOpen($any($event))"
(mouseenter)="onMouseEnter($event)"
(keydown.space)="onSpace($any($event))"
(keydown.esc)="back()"
(keydown.arrowDown)="focusOnNode($any($event))"
(focus)="depth || reinitializeMenu()"
>
<ng-container *ngIf="!node.url">
{{ node.title }}
</ng-container>
<cx-icon [type]="iconType.CARET_DOWN"></cx-icon>
</button>
</ng-container>
</ng-container>
<ng-template #title>
<span *ngIf="node.title" [attr.tabindex]="-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,7 +23,7 @@ class MockIconComponent {

@Component({
selector: 'cx-generic-link',
template: '{{title}}',
template: '<a href={{url}}>{{title}}</a>',
})
class MockGenericLinkComponent {
@Input() url: string | any[];
Expand All @@ -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' },
Expand Down Expand Up @@ -98,6 +110,8 @@ describe('Navigation UI Component', () => {
NavigationUIComponent,
MockIconComponent,
MockGenericLinkComponent,
// TODO: (CXSPA-5919) Remove feature directive next major
MockFeatureDirective,
],
providers: [
{
Expand All @@ -108,6 +122,10 @@ describe('Navigation UI Component', () => {
provide: WindowRef,
useValue: mockWinRef,
},
{
provide: FeatureConfigService,
useClass: MockFeatureConfigService,
},
],
}).compileComponents();
});
Expand Down Expand Up @@ -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);
});
});
});
Loading

0 comments on commit d0c9164

Please sign in to comment.