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 8 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 @@ -12,7 +12,7 @@ context('Auxiliary Keys', () => {
loadPageWithComponenents('/');
});

it('should open and close menu with space key', () => {
it('should open menu with space key', () => {
Pio-Bar marked this conversation as resolved.
Show resolved Hide resolved
cy.get('cx-category-navigation').within(() => {
cy.get('cx-navigation-ui')
.find('li:not(.back)')
Expand All @@ -37,15 +37,6 @@ context('Auxiliary Keys', () => {
.should('have.length', 7)
.first()
.should('be.visible');
cy.focused().trigger('keydown', {
key: ' ',
code: 'Space',
force: true,
});
cy.get('div.wrapper')
.should('have.length', 7)
.first()
.should('not.be.visible');
});
});
});
Expand All @@ -57,7 +48,7 @@ context('Auxiliary Keys', () => {
loadPageWithComponenents('/');
});

it('should open and close menu with space key', () => {
it('should open menu with space key', () => {
cy.get('cx-page-layout[section="header"]').within(() => {
cy.get('cx-navigation-ui.accNavComponent')
.should('contain.text', 'My Account')
Expand All @@ -77,14 +68,6 @@ context('Auxiliary Keys', () => {
cy.get('cx-generic-link')
.contains('Order History')
.should('be.visible');
cy.focused().trigger('keydown', {
key: ' ',
code: 'Space',
force: true,
});
cy.get('cx-generic-link')
.contains('Order History')
.should('not.be.visible');
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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"]').as('firstDropdown');
});
it('opens a node after pressing space', () => {
Pio-Bar marked this conversation as resolved.
Show resolved Hide resolved
cy.get('@firstDropdown').each((dropdownButton) => {
cy.wrap(dropdownButton).as('dropdownButton');
cy.get('@dropdownButton').focus().type(' ').next().should('be.visible');
});
});
it('navigates node children with downArrow key', () => {
cy.get('@firstDropdown').first().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').first().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
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
[style]="node.styleAttributes"
[class]="node.styleClasses"
(click)="closeIfClickedTheSameLink(node)"
[tabindex]="depth > 0 && !node.children ? -1 : 0"
(focus)="depth || reinitializeMenu()"
>
{{ node.title }}
</cx-generic-link>
Expand All @@ -45,14 +47,15 @@
{{ 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.space)="onSpace($any($event))"
Pio-Bar marked this conversation as resolved.
Show resolved Hide resolved
(keydown.esc)="back()"
(keydown.arrowDown)="focusOnNode($any($event))"
Pio-Bar marked this conversation as resolved.
Show resolved Hide resolved
(focus)="depth || reinitializeMenu()"
>
<ng-container *ngIf="!node.url">
{{ node.title }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class MockIconComponent {

@Component({
selector: 'cx-generic-link',
template: '{{title}}',
template: '<a href={{url}}>{{title}}</a>',
})
class MockGenericLinkComponent {
@Input() url: string | any[];
Expand Down Expand Up @@ -306,4 +306,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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ import {
} 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 { 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';
Expand Down Expand Up @@ -64,12 +69,19 @@ export class NavigationUIComponent implements OnInit, OnDestroy {
private openNodes: HTMLElement[] = [];
private subscriptions = new Subscription();
private resize = new EventEmitter();
protected arrowControls: Subject<KeyboardEvent> = 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,
Expand Down Expand Up @@ -117,12 +129,6 @@ export class NavigationUIComponent implements OnInit, OnDestroy {
typeof navNode.url === 'string' &&
this.winRef.nativeWindow?.location.href.includes(navNode.url)
) {
this.elemRef.nativeElement
Zeyber marked this conversation as resolved.
Show resolved Hide resolved
.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();
}
Expand All @@ -133,8 +139,13 @@ export class NavigationUIComponent implements OnInit, OnDestroy {
*/
reinitializeMenu(): void {
if (this.openNodes?.length > 0) {
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');
}
}

Expand Down Expand Up @@ -173,6 +184,45 @@ export class NavigationUIComponent implements OnInit, OnDestroy {
event.stopPropagation();
}

onSpace(event: UIEvent): void {
Zeyber marked this conversation as resolved.
Show resolved Hide resolved
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();
}

setupArrowControls(): void {
this.subscriptions.add(
this.arrowControls.subscribe((e) => {
e.preventDefault();
const parentElement = (<HTMLElement>e.target).parentElement
?.parentElement;
const nextLink = parentElement?.nextElementSibling?.querySelector('a');
const previousLink =
parentElement?.previousElementSibling?.querySelector('a');
e.code === 'ArrowDown' ? nextLink?.focus() : previousLink?.focus();
})
);
}

focusOnNode(event: UIEvent): void {
const firstFocusableElement =
(<HTMLElement>event.target).nextElementSibling?.querySelector('button') ||
(<HTMLElement>event.target).nextElementSibling?.querySelector('a');
firstFocusableElement?.focus();
}

back(): void {
if (this.openNodes[this.openNodes.length - 1]) {
this.renderer.removeClass(
Expand All @@ -186,7 +236,6 @@ export class NavigationUIComponent implements OnInit, OnDestroy {

clear(): void {
this.openNodes = [];
this.updateClasses();
Zeyber marked this conversation as resolved.
Show resolved Hide resolved
}

onMouseEnter(event: MouseEvent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
[attr.class]="class"
[attr.style]="style"
[attr.title]="title"
[tabindex]="tabindex"
>
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
Expand All @@ -24,6 +25,7 @@
[attr.class]="class"
[attr.style]="style"
[attr.title]="title"
[tabindex]="tabindex"
>
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading