Skip to content

Commit

Permalink
fix: programmatically click product menu item on space/enter and add …
Browse files Browse the repository at this point in the history
…keyboard arrow navigation (#3187)

* programmatically click product menu item on space/enter

* arrow key support for product switch

* start tests

* tests

* address pr comments
  • Loading branch information
mikerodonnell89 committed Sep 8, 2020
1 parent 63296c4 commit 6d401b9
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
tabindex="0"
class="fd-product-switch__item"
[ngClass]="{ selected: product.selected }"
(keydown)="keyDownHandle($event)"
(click)="itemClick(product, $event)"
[draggable]="!product.disabledDragAndDrop && dragAndDropEnabled"
[stickInPlace]="product.stickToPosition">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,140 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { ProductSwitchBodyComponent } from './product-switch-body.component';
import { ButtonModule, PopoverModule } from '@fundamental-ngx/core';
import { ButtonModule, PopoverModule, ProductSwitchItem } from '@fundamental-ngx/core';
import { DragAndDropModule } from '../../utils/drag-and-drop/drag-and-drop.module';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { createKeyboardEvent } from '../../utils/tests/event-objects';
import { DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, UP_ARROW } from '@angular/cdk/keycodes';

@Component({
selector: 'fd-test-component',
template: '<fd-product-switch-body [products]="list"> </fd-product-switch-body>'
})
export class TestComponent {
list: ProductSwitchItem[] = [
{
title: 'Home',
subtitle: 'Central Home',
icon: 'home',
stickToPosition: true,
disabledDragAndDrop: true
},
{
title: 'Analytics Cloud',
subtitle: 'Analytics Cloud',
icon: 'business-objects-experience',
selected: true
},
{
title: 'Catalog',
subtitle: 'Ariba',
icon: 'contacts'
},
{
title: 'Guided Buying',
icon: 'credit-card'
},
{
title: 'Strategic Procurement',
icon: 'cart-3'
}
];
}

describe('ProductSwitchBodyComponent', () => {
let component: ProductSwitchBodyComponent;
let fixture: ComponentFixture<ProductSwitchBodyComponent>;
let fixture: ComponentFixture<TestComponent>, debugElement: DebugElement, element: HTMLElement;

let component, componentInstance: ProductSwitchBodyComponent;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [PopoverModule, ButtonModule, DragAndDropModule, DragDropModule],
declarations: [ProductSwitchBodyComponent]
}).compileComponents();
declarations: [ProductSwitchBodyComponent, TestComponent]
});
}));

beforeEach(() => {
fixture = TestBed.createComponent(ProductSwitchBodyComponent);
component = fixture.componentInstance;
fixture = TestBed.createComponent(TestComponent);
debugElement = fixture.debugElement;
element = debugElement.nativeElement;
fixture.detectChanges();
component = debugElement.query(By.directive(ProductSwitchBodyComponent));
componentInstance = component.injector.get(ProductSwitchBodyComponent);
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should handle keydown enter', () => {
const el = fixture.debugElement.query(By.css('li'));
spyOn(el.nativeElement, 'click');
el.nativeElement.focus();
const keyboardEvent = createKeyboardEvent('keydown', ENTER, 'Enter', el.nativeElement);
spyOn(keyboardEvent, 'preventDefault');
el.nativeElement.dispatchEvent(keyboardEvent);

expect(keyboardEvent.preventDefault).toHaveBeenCalled();
expect(el.nativeElement.click).toHaveBeenCalled();
});

it('should handle no list keydown arrow right', () => {
spyOn(componentInstance, 'isListMode').and.returnValue(false);
const el = fixture.debugElement.query(By.css('li'));
const nextEl = el.nativeElement.nextElementSibling;
el.nativeElement.focus();
const keyboardEvent = createKeyboardEvent('keydown', RIGHT_ARROW, 'ArrowRight', el.nativeElement);
el.nativeElement.dispatchEvent(keyboardEvent);

expect(document.activeElement).toBe(nextEl);
});

it('should handle no list keydown arrow left', () => {
spyOn(componentInstance, 'isListMode').and.returnValue(false);
const el = fixture.debugElement.query(By.css('li'));
const nextEl = el.nativeElement.nextElementSibling;
nextEl.focus();
const keyboardEvent = createKeyboardEvent('keydown', LEFT_ARROW, 'ArrowLeft', nextEl);
nextEl.dispatchEvent(keyboardEvent);

expect(document.activeElement).toBe(el.nativeElement);
});

it('should handle no list keydown arrow down', () => {
spyOn(componentInstance, 'isListMode').and.returnValue(false);
const el = fixture.debugElement.query(By.css('li'));
const nextElDown = el.nativeElement.nextElementSibling.nextElementSibling.nextElementSibling;
el.nativeElement.focus();
const keyboardEvent = createKeyboardEvent('keydown', DOWN_ARROW, 'ArrowDown', el.nativeElement);
el.nativeElement.dispatchEvent(keyboardEvent);

expect(document.activeElement).toBe(nextElDown);
});

it('should handle no list keydown arrow up', () => {
spyOn(componentInstance, 'isListMode').and.returnValue(false);
const el = fixture.debugElement.query(By.css('li'));
const nextElDown = el.nativeElement.nextElementSibling.nextElementSibling.nextElementSibling;
nextElDown.focus();
const keyboardEvent = createKeyboardEvent('keydown', UP_ARROW, 'ArrowUp', nextElDown);
nextElDown.dispatchEvent(keyboardEvent);

expect(document.activeElement).toBe(el.nativeElement);
});

it('should handle list arrow up/down', () => {
spyOn(componentInstance, 'isListMode').and.returnValue(true);
const el = fixture.debugElement.query(By.css('li'));
const nextElDown = el.nativeElement.nextElementSibling;
el.nativeElement.focus();
let keyboardEvent = createKeyboardEvent('keydown', DOWN_ARROW, 'ArrowDown', el.nativeElement);
el.nativeElement.dispatchEvent(keyboardEvent);
expect(document.activeElement).toBe(nextElDown);
keyboardEvent = createKeyboardEvent('keydown', UP_ARROW, 'ArrowUp', nextElDown);
nextElDown.dispatchEvent(keyboardEvent);
expect(document.activeElement).toBe(el.nativeElement);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@angular/core';
import { ProductSwitchItem } from './product-switch.item';
import { FdDropEvent } from '../../utils/drag-and-drop/dnd-list/dnd-list.directive';
import { KeyUtil } from '../../utils/public_api';

@Component({
selector: 'fd-product-switch-body',
Expand Down Expand Up @@ -49,7 +50,7 @@ export class ProductSwitchBodyComponent implements OnInit {

/** @hidden */
ngOnInit(): void {
this.checkSize();
this._checkSize();
}

/**
Expand All @@ -71,7 +72,23 @@ export class ProductSwitchBodyComponent implements OnInit {
/** @hidden */
@HostListener('window:resize', [])
onResize(): void {
this.checkSize();
this._checkSize();
}

/** @hidden */
keyDownHandle(event: KeyboardEvent): void {
const target = <HTMLElement>event.target;
const i = Array.from(target.parentElement.children).indexOf(target);
if (!KeyUtil.isKey(event, 'Tab')) {
event.preventDefault();
}
if (KeyUtil.isKey(event, ['Enter', ' '])) {
target.click();
} else if (!this.isListMode()) {
this._handleNoListKeydown(event, target, i);
} else if (this.isListMode() && KeyUtil.isKey(event, ['ArrowDown', 'ArrowUp'])) {
this._handleListArrowUpDown(event, target);
}
}

/** @hidden */
Expand All @@ -85,11 +102,63 @@ export class ProductSwitchBodyComponent implements OnInit {
}

/** @hidden */
private checkSize(): void {
private _checkSize(): void {
if (this.isSmallMode()) {
this.listMode = window.innerWidth < 588;
} else {
this.listMode = window.innerWidth < 776;
}
}

/** @hidden */
private _handleNoListKeydown(event: KeyboardEvent, target: HTMLElement, i: number): void {
if (KeyUtil.isKey(event, 'ArrowLeft') && target.previousElementSibling) {
(<HTMLElement>target.previousElementSibling).focus();
} else if (KeyUtil.isKey(event, 'ArrowRight') && target.nextElementSibling) {
(<HTMLElement>target.nextElementSibling).focus();
} else if (KeyUtil.isKey(event, ['ArrowDown', 'ArrowUp'])) {
if (this.products.length >= 7) {
this._handleNoListMoreThanSeven(event, target, i);
} else if (this.products.length < 7) {
this._handleNoListLessThanSeven(event, target, i);
}
}
}

/** @hidden */
private _handleNoListMoreThanSeven(event: KeyboardEvent, target: HTMLElement, i: number): void {
if (KeyUtil.isKey(event, 'ArrowDown')) {
if (target.parentElement.children[i + 4]) {
(<HTMLElement>target.parentElement.children[i + 4]).focus();
}
}
if (KeyUtil.isKey(event, 'ArrowUp')) {
if (target.parentElement.children[i - 4]) {
(<HTMLElement>target.parentElement.children[i - 4]).focus();
}
}
}

/** @hidden */
private _handleNoListLessThanSeven(event: KeyboardEvent, target: HTMLElement, i: number): void {
if (KeyUtil.isKey(event, 'ArrowDown')) {
if (target.parentElement.children[i + 3]) {
(<HTMLElement>target.parentElement.children[i + 3]).focus();
}
}
if (KeyUtil.isKey(event, 'ArrowUp')) {
if (target.parentElement.children[i - 3]) {
(<HTMLElement>target.parentElement.children[i - 3]).focus();
}
}
}

/** @hidden */
private _handleListArrowUpDown(event: KeyboardEvent, target: HTMLElement): void {
if (this.isListMode() && KeyUtil.isKey(event, 'ArrowDown') && target.nextElementSibling) {
(<HTMLElement>target.nextElementSibling).focus();
} else if (this.isListMode() && KeyUtil.isKey(event, 'ArrowUp') && target.previousElementSibling) {
(<HTMLElement>target.previousElementSibling).focus();
}
}
}
91 changes: 91 additions & 0 deletions libs/core/src/lib/utils/tests/event-objects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { ModifierKeys } from '@angular/cdk/testing';

/**
* Dispatches a keydown event from an element.
* @docs-private
*/
export function createKeyboardEvent(
type: string,
keyCode: number = 0,
key: string = '',
target?: Element,
modifiers: ModifierKeys = {}
): KeyboardEvent {
const event = document.createEvent('KeyboardEvent') as any;
const originalPreventDefault = event.preventDefault;

// Firefox does not support `initKeyboardEvent`, but supports `initKeyEvent`.
if (event.initKeyEvent) {
event.initKeyEvent(
type,
true,
true,
window,
modifiers.control,
modifiers.alt,
modifiers.shift,
modifiers.meta,
keyCode
);
} else {
// `initKeyboardEvent` expects to receive modifiers as a whitespace-delimited string
// See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/initKeyboardEvent
let modifiersList = '';

if (modifiers.control) {
modifiersList += 'Control ';
}

if (modifiers.alt) {
modifiersList += 'Alt ';
}

if (modifiers.shift) {
modifiersList += 'Shift ';
}

if (modifiers.meta) {
modifiersList += 'Meta ';
}

event.initKeyboardEvent(
type,
true /* canBubble */,
true /* cancelable */,
window /* view */,
0 /* char */,
key /* key */,
0 /* location */,
modifiersList.trim() /* modifiersList */,
false /* repeat */
);
}

// Webkit Browsers don't set the keyCode when calling the init function.
// See related bug https://bugs.webkit.org/show_bug.cgi?id=16735
Object.defineProperties(event, {
keyCode: { get: () => keyCode },
key: { get: () => key },
target: { get: () => target },
ctrlKey: { get: () => !!modifiers.control },
altKey: { get: () => !!modifiers.alt },
shiftKey: { get: () => !!modifiers.shift },
metaKey: { get: () => !!modifiers.meta }
});

// IE won't set `defaultPrevented` on synthetic events so we need to do it manually.
event.preventDefault = function (): Event {
Object.defineProperty(event, 'defaultPrevented', { get: () => true });
return originalPreventDefault.apply(this, arguments);
};

return event;
}

0 comments on commit 6d401b9

Please sign in to comment.