Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: (CXSPA-981) - Add keyboard navigation to 'navigation-ui' #18297

Merged
merged 23 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
Zeyber marked this conversation as resolved.
Show resolved Hide resolved
});
});
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;
Pio-Bar marked this conversation as resolved.
Show resolved Hide resolved
}
}

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
Loading