From 926c07605717e9df58d06d9297b349bcccc114d6 Mon Sep 17 00:00:00 2001 From: Dominic Carretto Date: Thu, 12 Sep 2019 20:34:25 -0400 Subject: [PATCH] feat(menu): Support default focus state (#2004) - Adds `defaultFocusState: 'none' | 'list' | 'firstItem' | 'lastItem'` for `MdcMenu` - Minor code refactoring of `MdcMenuSurfaceBase` - Update documentation - Include unit test closes #1998 --- demos/src/app/components/menu-demo/api.html | 10 +- .../app/components/menu-demo/examples.html | 7 ++ .../src/app/components/menu-demo/menu-demo.ts | 40 +++---- packages/menu-surface/menu-surface-base.ts | 113 +++++++++--------- packages/menu/menu.ts | 34 ++++-- test/menu/menu.test.ts | 24 +++- 6 files changed, 135 insertions(+), 93 deletions(-) diff --git a/demos/src/app/components/menu-demo/api.html b/demos/src/app/components/menu-demo/api.html index cf146d630..d603b8f78 100644 --- a/demos/src/app/components/menu-demo/api.html +++ b/demos/src/app/components/menu-demo/api.html @@ -54,15 +54,9 @@

Properties

closeSurfaceOnSelection: boolean Sets whether the menu surface should close after item selection. Default is true - - - -

Methods

- - - - + +
focus()Set focus to the menu.defaultFocusState: 'none' | 'list' | 'firstItem' | 'lastItem'Sets default focus state where the menu should focus every time when menu is opened. Focuses the list root ('list') element by default.
diff --git a/demos/src/app/components/menu-demo/examples.html b/demos/src/app/components/menu-demo/examples.html index d30ddbabc..779958688 100644 --- a/demos/src/app/components/menu-demo/examples.html +++ b/demos/src/app/components/menu-demo/examples.html @@ -44,6 +44,12 @@

Anchor Margin

+ + + + + + @@ -57,6 +63,7 @@

Anchor Margin

[anchorElement]="demoAnchor" [anchorCorner]="menuSurfaceAnchorCorner.value" [quickOpen]="quickOpen.checked" + [defaultFocusState]="defaultFocusState.value" [fixed]="fixed.checked" [wrapFocus]="wrapFocus.checked" [closeSurfaceOnSelection]="closeSurfaceOnSelection.checked" diff --git a/demos/src/app/components/menu-demo/menu-demo.ts b/demos/src/app/components/menu-demo/menu-demo.ts index d0ebf4e33..14aa6e949 100644 --- a/demos/src/app/components/menu-demo/menu-demo.ts +++ b/demos/src/app/components/menu-demo/menu-demo.ts @@ -1,9 +1,9 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; +import {Component, OnInit, ViewChild} from '@angular/core'; -import { MdcListItem } from '@angular-mdc/web'; -import { ComponentViewer, ComponentView } from '../../shared/component-viewer'; +import {MdcListItem} from '@angular-mdc/web'; +import {ComponentViewer, ComponentView} from '../../shared/component-viewer'; -@Component({ template: '' }) +@Component({template: ''}) export class MenuDemo implements OnInit { @ViewChild(ComponentViewer, {static: true}) _componentViewer: ComponentViewer; @@ -23,36 +23,36 @@ export class MenuDemo implements OnInit { } } -@Component({ templateUrl: './api.html' }) -export class Api { } +@Component({templateUrl: './api.html'}) +export class Api {} -@Component({ templateUrl: './sass.html' }) -export class Sass { } +@Component({templateUrl: './sass.html'}) +export class Sass {} -@Component({ templateUrl: './examples.html' }) +@Component({templateUrl: './examples.html'}) export class Examples { corners: string[] = ['topStart', 'topEnd', 'bottomStart', 'bottomEnd']; fruits = [ - { label: 'Passionfruit' }, - { label: 'Orange' }, - { label: 'Guava' }, - { label: 'Pitaya' }, - { label: null }, // null label sets a mdc-list-divider - { label: 'Pinaeapple' }, - { label: 'Mango' }, - { label: 'Papaya' }, - { label: 'Lychee' } + {label: 'Passionfruit'}, + {label: 'Orange'}, + {label: 'Guava'}, + {label: 'Pitaya'}, + {label: null}, // null label sets a mdc-list-divider + {label: 'Pinaeapple'}, + {label: 'Mango'}, + {label: 'Papaya'}, + {label: 'Lychee'} ]; lastSelection: number; - onMenuSelect(event: { index: number, item: MdcListItem }) { + onMenuSelect(event: {index: number, item: MdcListItem}) { this.lastSelection = event.index; } addFruit(): void { - this.fruits.push({ label: 'New fruit item' }); + this.fruits.push({label: 'New fruit item'}); } exampleMenu = { diff --git a/packages/menu-surface/menu-surface-base.ts b/packages/menu-surface/menu-surface-base.ts index 15d9c975c..5aa04e164 100644 --- a/packages/menu-surface/menu-surface-base.ts +++ b/packages/menu-surface/menu-surface-base.ts @@ -48,7 +48,9 @@ export abstract class MdcMenuSurfaceBase extends MDCComponent this._getHostElement() === el || this._getHostElement().contains(el), - isRtl: () => { - if (!this.platform.isBrowser) { return false; } - - return window.getComputedStyle(this._getHostElement()).getPropertyValue('direction') === 'rtl'; - }, - setTransformOrigin: (origin: string) => { - if (!this.platform.isBrowser) { return; } - - this._getHostElement().style[`${util.getTransformPropertyName(window)}-origin` as any] = origin; - }, + isRtl: () => this.platform.isBrowser ? + window.getComputedStyle(this._getHostElement()).getPropertyValue('direction') === 'rtl' : false, + setTransformOrigin: (origin: string) => + this.platform.isBrowser ? + this._getHostElement().style[`${util.getTransformPropertyName(window)}-origin` as any] = origin : false, isFocused: () => this.platform.isBrowser ? document.activeElement! === this._getHostElement() : false, - saveFocus: () => { - if (!this.platform.isBrowser) { return; } - this._previousFocus = document.activeElement!; - }, + saveFocus: () => this.platform.isBrowser ? this._previousFocus = document.activeElement! : {}, restoreFocus: () => { - if (!this.platform.isBrowser) { return; } - - if (this._getHostElement().contains(document.activeElement!)) { + if (!this.platform.isBrowser && this._getHostElement().contains(document.activeElement!)) { if (this._previousFocus && (this._previousFocus).focus) { (this._previousFocus).focus(); } } }, - getInnerDimensions: () => { - return { width: this._getHostElement().offsetWidth, height: this._getHostElement().offsetHeight }; - }, - getAnchorDimensions: () => { - if (!this.platform.isBrowser || !this.anchorElement) { return null; } - return this._anchorElement!.getBoundingClientRect(); - }, - getWindowDimensions: () => { - return { - width: this.platform.isBrowser ? window.innerWidth : 0, - height: this.platform.isBrowser ? window.innerHeight : 0 - }; - }, - getBodyDimensions: () => { - return { - width: this.platform.isBrowser ? document.body!.clientWidth : 0, - height: this.platform.isBrowser ? document.body!.clientHeight : 0 - }; - }, - getWindowScroll: () => { - return { - x: this.platform.isBrowser ? window.pageXOffset : 0, - y: this.platform.isBrowser ? window.pageYOffset : 0 - }; - }, - setPosition: (position: { left: number, right: number, top: number, bottom: number }) => { + getInnerDimensions: () => + ({width: this._getHostElement().offsetWidth, height: this._getHostElement().offsetHeight}), + getAnchorDimensions: () => + this.platform.isBrowser || !this.anchorElement ? + this._anchorElement!.getBoundingClientRect() : {top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0}, + getWindowDimensions: () => ({ + width: this.platform.isBrowser ? window.innerWidth : 0, + height: this.platform.isBrowser ? window.innerHeight : 0 + }), + getBodyDimensions: () => ({ + width: this.platform.isBrowser ? document.body!.clientWidth : 0, + height: this.platform.isBrowser ? document.body!.clientHeight : 0 + }), + getWindowScroll: () => ({ + x: this.platform.isBrowser ? window.pageXOffset : 0, + y: this.platform.isBrowser ? window.pageYOffset : 0 + }), + setPosition: (position: {left: number, right: number, top: number, bottom: number}) => { this._getHostElement().style.left = 'left' in position ? `${position.left}px` : ''; this._getHostElement().style.right = 'right' in position ? `${position.right}px` : ''; this._getHostElement().style.top = 'top' in position ? `${position.top}px` : ''; @@ -206,7 +204,6 @@ export abstract class MdcMenuSurfaceBase extends MDCComponent) { - super(elementRef); } @@ -233,14 +230,16 @@ export abstract class MdcMenuSurfaceBase extends MDCComponent diff --git a/packages/menu/menu.ts b/packages/menu/menu.ts index e3dbaf262..a89e3602f 100644 --- a/packages/menu/menu.ts +++ b/packages/menu/menu.ts @@ -21,7 +21,7 @@ import {MdcList, MdcListItem, MdcListItemAction} from '@angular-mdc/web/list'; import {MdcMenuSurfaceBase} from '@angular-mdc/web/menu-surface'; import {closest} from '@material/dom/ponyfill'; -import {cssClasses, MDCMenuFoundation} from '@material/menu'; +import {cssClasses, strings, DefaultFocusState, MDCMenuFoundation} from '@material/menu'; export class MdcMenuSelectedEvent { constructor( @@ -31,6 +31,15 @@ export class MdcMenuSelectedEvent { let nextUniqueId = 0; +export type MdcMenuFocusState = 'none' | 'list' | 'firstItem' | 'lastItem'; + +const DEFAULT_FOCUS_STATE_MAP = { + none: DefaultFocusState.NONE, + list: DefaultFocusState.LIST_ROOT, + firstItem: DefaultFocusState.FIRST_ITEM, + lastItem: DefaultFocusState.LAST_ITEM +}; + @Directive({ selector: '[mdcMenuSelectionGroup], mdc-menu-selection-group', host: {'class': 'mdc-menu__selection-group'}, @@ -95,6 +104,18 @@ export class MdcMenu extends MdcMenuSurfaceBase implements AfterContentInit, OnD } private _closeSurfaceOnSelection: boolean = true; + @Input() + get defaultFocusState(): MdcMenuFocusState { + return this._defaultFocusState; + } + set defaultFocusState(value: MdcMenuFocusState) { + if (value !== this._defaultFocusState) { + this._defaultFocusState = value; + this._menuFoundation.setDefaultFocusState(DEFAULT_FOCUS_STATE_MAP[this._defaultFocusState]); + } + } + private _defaultFocusState: MdcMenuFocusState = 'list'; + @Output() readonly selected: EventEmitter = new EventEmitter(); @ContentChild(MdcList, {static: false}) _list!: MdcList; @@ -118,8 +139,8 @@ export class MdcMenu extends MdcMenuSurfaceBase implements AfterContentInit, OnD notifySelected: (evtData: {index: number}) => this.selected.emit(new MdcMenuSelectedEvent(evtData.index, this.listItems.toArray()[evtData.index])), getMenuItemCount: () => this.listItems.toArray().length, - focusItemAtIndex: (index: number) => this._list.getListItemByIndex(index)!.focus(), - focusListRoot: () => this._list.focus(), + focusItemAtIndex: (index: number) => this.listItems.toArray()[index].focus(), + focusListRoot: () => (this.elementRef.nativeElement.querySelector(strings.LIST_SELECTOR) as HTMLElement).focus(), isSelectableItemAtIndex: (index: number) => !!closest(this.listItems.toArray()[index].getListItemElement(), `.${cssClasses.MENU_SELECTION_GROUP}`), getSelectedSiblingOfItemAtIndex: (index: number) => { @@ -136,7 +157,8 @@ export class MdcMenu extends MdcMenuSurfaceBase implements AfterContentInit, OnD destroy(): void, handleKeydown(evt: KeyboardEvent): void, handleItemAction(listItem: HTMLElement): void, - handleMenuSurfaceOpened(): void + handleMenuSurfaceOpened(): void, + setDefaultFocusState(focusState: DefaultFocusState): void } = new MDCMenuFoundation(this._createAdapter()); ngAfterContentInit(): void { @@ -156,10 +178,6 @@ export class MdcMenu extends MdcMenuSurfaceBase implements AfterContentInit, OnD this._menuFoundation.destroy(); } - focus(): void { - this._getHostElement().focus(); - } - _handleKeydown(evt: KeyboardEvent): void { this._menuFoundation.handleKeydown(evt); } diff --git a/test/menu/menu.test.ts b/test/menu/menu.test.ts index d24d039fc..2f4311ea6 100644 --- a/test/menu/menu.test.ts +++ b/test/menu/menu.test.ts @@ -167,6 +167,26 @@ describe('MdcMenu', () => { expect(listItemInstance.getListItemElement().classList.contains('.mdc-menu--selected')); })); + + it('#menu should set default focus state to firstItem', () => { + testComponent.defaultFocusState = 'firstItem'; + fixture.detectChanges(); + expect(testInstance.defaultFocusState).toBe('firstItem'); + }); + + it('#menu should select first item', () => { + testInstance.listItems.first.elementRef.nativeElement.click(); + fixture.detectChanges(); + }); + + it('#menu should select first item, then second item', fakeAsync(() => { + testInstance.listItems.first.elementRef.nativeElement.click(); + fixture.detectChanges(); + flush(); + + testInstance.listItems.toArray()[1].elementRef.nativeElement.click(); + fixture.detectChanges(); + })); }); }); @@ -202,7 +222,8 @@ class MenuTest { @Component({ template: `
- + @@ -233,4 +254,5 @@ class MenuTest { }) class MenuSelectionGroup { open: boolean; + defaultFocusState = 'list'; }