diff --git a/packages/mdc-list/component.ts b/packages/mdc-list/component.ts index ee1fb649207..70f3f48adb4 100644 --- a/packages/mdc-list/component.ts +++ b/packages/mdc-list/component.ts @@ -141,10 +141,6 @@ export class MDCList extends MDCComponent { initializeListType() { const checkboxListItems = this.root.querySelectorAll(strings.ARIA_ROLE_CHECKBOX_SELECTOR); - const singleSelectedListItem = this.root.querySelector(` - .${cssClasses.LIST_ITEM_ACTIVATED_CLASS}, - .${cssClasses.LIST_ITEM_SELECTED_CLASS} - `); const radioSelectedListItem = this.root.querySelector(strings.ARIA_CHECKED_RADIO_SELECTOR); @@ -153,13 +149,6 @@ export class MDCList extends MDCComponent { this.root.querySelectorAll(strings.ARIA_CHECKED_CHECKBOX_SELECTOR); this.selectedIndex = [].map.call(preselectedItems, (listItem: Element) => this.listElements.indexOf(listItem)) as number[]; - } else if (singleSelectedListItem) { - if (singleSelectedListItem.classList.contains(cssClasses.LIST_ITEM_ACTIVATED_CLASS)) { - this.foundation.setUseActivatedClass(true); - } - - this.singleSelection = true; - this.selectedIndex = this.listElements.indexOf(singleSelectedListItem); } else if (radioSelectedListItem) { this.selectedIndex = this.listElements.indexOf(radioSelectedListItem); } @@ -226,7 +215,8 @@ export class MDCList extends MDCComponent { return toggleEl!.checked; }, isFocusInsideList: () => { - return this.root.contains(document.activeElement); + return this.root !== document.activeElement && + this.root.contains(document.activeElement); }, isRootFocused: () => document.activeElement === this.root, listItemAtIndexHasClass: (index, className) => diff --git a/packages/mdc-list/foundation.ts b/packages/mdc-list/foundation.ts index b6b3c9450ac..51b77bbd42b 100644 --- a/packages/mdc-list/foundation.ts +++ b/packages/mdc-list/foundation.ts @@ -21,6 +21,9 @@ * THE SOFTWARE. */ +// TODO(b/152410470): Remove trailing underscores from private properties +// tslint:disable:strip-private-property-underscore + import {MDCFoundation} from '@material/base/foundation'; import {normalizeKey} from '@material/dom/keyboard'; @@ -97,6 +100,8 @@ export class MDCListFoundation extends MDCFoundation { this.isCheckboxList_ = true; } else if (this.adapter.hasRadioAtIndex(0)) { this.isRadioList_ = true; + } else { + this.maybeInitializeSingleSelection(); } if (this.hasTypeahead) { @@ -123,6 +128,33 @@ export class MDCListFoundation extends MDCFoundation { */ setSingleSelection(value: boolean) { this.isSingleSelectionList_ = value; + if (value) { + this.maybeInitializeSingleSelection(); + } + } + + /** + * Automatically determines whether the list is single selection list. If so, + * initializes the internal state to match the selected item. + */ + private maybeInitializeSingleSelection() { + for (let i = 0; i < this.adapter.getListItemCount(); i++) { + const hasSelectedClass = this.adapter.listItemAtIndexHasClass( + i, cssClasses.LIST_ITEM_SELECTED_CLASS); + const hasActivatedClass = this.adapter.listItemAtIndexHasClass( + i, cssClasses.LIST_ITEM_ACTIVATED_CLASS); + if (!(hasSelectedClass || hasActivatedClass)) { + continue; + } + + if (hasActivatedClass) { + this.setUseActivatedClass(true); + } + + this.isSingleSelectionList_ = true; + this.selectedIndex_ = i; + return; + } } /** @@ -175,6 +207,7 @@ export class MDCListFoundation extends MDCFoundation { handleFocusIn(_: FocusEvent, listItemIndex: number) { if (listItemIndex >= 0) { this.focusedItemIndex = listItemIndex; + this.adapter.setAttributeForElementIndex(listItemIndex, 'tabindex', '0'); this.adapter.setTabIndexForListItemChildren(listItemIndex, '0'); } } @@ -184,6 +217,7 @@ export class MDCListFoundation extends MDCFoundation { */ handleFocusOut(_: FocusEvent, listItemIndex: number) { if (listItemIndex >= 0) { + this.adapter.setAttributeForElementIndex(listItemIndex, 'tabindex', '-1'); this.adapter.setTabIndexForListItemChildren(listItemIndex, '-1'); } @@ -193,7 +227,7 @@ export class MDCListFoundation extends MDCFoundation { */ setTimeout(() => { if (!this.adapter.isFocusInsideList()) { - this.setTabindexToFirstSelectedItem_(); + this.setTabindexToFirstSelectedOrFocusedItem(); } }, 0); } @@ -225,7 +259,7 @@ export class MDCListFoundation extends MDCFoundation { const handleKeydownOpts: typeahead.HandleKeydownOpts = { event, focusItemAtIndex: (index) => { - this.focusItemAtIndex(index) + this.focusItemAtIndex(index); }, focusedItemIndex: -1, isTargetListItem: isRootListItem, @@ -311,9 +345,6 @@ export class MDCListFoundation extends MDCFoundation { return; } - this.setTabindexAtIndex_(index); - this.focusedItemIndex = index; - if (this.adapter.listItemAtIndexHasClass( index, cssClasses.LIST_ITEM_DISABLED_CLASS)) { return; @@ -409,8 +440,12 @@ export class MDCListFoundation extends MDCFoundation { this.adapter.removeClassForElementIndex( this.selectedIndex_ as number, selectedClassName); } - this.adapter.addClassForElementIndex(index, selectedClassName); + this.setAriaForSingleSelectionAtIndex_(index); + this.setTabindexAtIndex_(index); + if (index !== numbers.UNSET_INDEX) { + this.adapter.addClassForElementIndex(index, selectedClassName); + } this.selectedIndex_ = index; } @@ -433,9 +468,12 @@ export class MDCListFoundation extends MDCFoundation { this.selectedIndex_ as number, ariaAttribute, 'false'); } - const ariaAttributeValue = isAriaCurrent ? this.ariaCurrentAttrValue_ : 'true'; - this.adapter.setAttributeForElementIndex( - index, ariaAttribute, ariaAttributeValue as string); + if (index !== numbers.UNSET_INDEX) { + const ariaAttributeValue = + isAriaCurrent ? this.ariaCurrentAttrValue_ : 'true'; + this.adapter.setAttributeForElementIndex( + index, ariaAttribute, ariaAttributeValue as string); + } } /** @@ -472,15 +510,27 @@ export class MDCListFoundation extends MDCFoundation { private setTabindexAtIndex_(index: number) { if (this.focusedItemIndex === numbers.UNSET_INDEX && index !== 0) { - // If no list item was selected set first list item's tabindex to -1. - // Generally, tabindex is set to 0 on first list item of list that has no preselected items. + // If some list item was selected set first list item's tabindex to -1. + // Generally, tabindex is set to 0 on first list item of list that has no + // preselected items. this.adapter.setAttributeForElementIndex(0, 'tabindex', '-1'); } else if (this.focusedItemIndex >= 0 && this.focusedItemIndex !== index) { this.adapter.setAttributeForElementIndex( this.focusedItemIndex, 'tabindex', '-1'); } - this.adapter.setAttributeForElementIndex(index, 'tabindex', '0'); + // Set the previous selection's tabindex to -1. We need this because + // in selection menus that are not visible, programmatically setting an + // option will not change focus but will change where tabindex should be 0. + if (!(this.selectedIndex_ instanceof Array) && + this.selectedIndex_ !== index) { + this.adapter.setAttributeForElementIndex( + this.selectedIndex_, 'tabindex', '-1'); + } + + if (index !== numbers.UNSET_INDEX) { + this.adapter.setAttributeForElementIndex(index, 'tabindex', '0'); + } } /** @@ -490,9 +540,8 @@ export class MDCListFoundation extends MDCFoundation { return this.isSingleSelectionList_ || this.isCheckboxList_ || this.isRadioList_; } - private setTabindexToFirstSelectedItem_() { - let targetIndex = 0; - + private setTabindexToFirstSelectedOrFocusedItem() { + let targetIndex = this.focusedItemIndex >= 0 ? this.focusedItemIndex : 0; if (this.isSelectableList_()) { if (typeof this.selectedIndex_ === 'number' && this.selectedIndex_ !== numbers.UNSET_INDEX) { targetIndex = this.selectedIndex_; @@ -519,7 +568,8 @@ export class MDCListFoundation extends MDCFoundation { if (this.isCheckboxList_) { throw new Error('MDCListFoundation: Expected array of index for checkbox based list but got number: ' + index); } - return this.isIndexInRange_(index); + return this.isIndexInRange_(index) || + this.isSingleSelectionList_ && index === numbers.UNSET_INDEX; } else { return false; } @@ -566,7 +616,6 @@ export class MDCListFoundation extends MDCFoundation { } private focusItemAtIndex(index: number) { - this.setTabindexAtIndex_(index); this.adapter.focusItemAtIndex(index); this.focusedItemIndex = index; } @@ -588,7 +637,7 @@ export class MDCListFoundation extends MDCFoundation { nextChar: string, startingIndex?: number, skipFocus = false) { const opts: typeahead.TypeaheadMatchItemOpts = { focusItemAtIndex: (index) => { - this.focusItemAtIndex(index) + this.focusItemAtIndex(index); }, focusedItemIndex: startingIndex ? startingIndex : this.focusedItemIndex, nextChar, diff --git a/packages/mdc-list/test/component.test.ts b/packages/mdc-list/test/component.test.ts index d069834e406..153f96fb332 100644 --- a/packages/mdc-list/test/component.test.ts +++ b/packages/mdc-list/test/component.test.ts @@ -165,45 +165,6 @@ describe('MDCList', () => { expect(mockFoundation.setVerticalOrientation).toHaveBeenCalledTimes(1); }); - it('#initializeListType sets the selectedIndex if a list item has the --selected class', - () => { - const {root, component, mockFoundation} = setupTest(); - (root.querySelector('.mdc-list-item') as HTMLElement) - .classList.add( - MDCListFoundation.cssClasses.LIST_ITEM_SELECTED_CLASS); - component.initializeListType(); - expect(mockFoundation.setSelectedIndex).toHaveBeenCalledWith(0); - expect(mockFoundation.setSelectedIndex).toHaveBeenCalledTimes(1); - expect(mockFoundation.setSingleSelection).toHaveBeenCalledWith(true); - expect(mockFoundation.setSingleSelection).toHaveBeenCalledTimes(1); - }); - - it('#initializeListType sets the selectedIndex if a list item has the --activated class', - () => { - const {root, component, mockFoundation} = setupTest(); - (root.querySelector('.mdc-list-item') as HTMLElement) - .classList.add( - MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS); - component.initializeListType(); - expect(mockFoundation.setSelectedIndex).toHaveBeenCalledWith(0); - expect(mockFoundation.setSelectedIndex).toHaveBeenCalledTimes(1); - expect(mockFoundation.setSingleSelection).toHaveBeenCalledWith(true); - expect(mockFoundation.setSingleSelection).toHaveBeenCalledTimes(1); - }); - - it('#initializeListType calls the foundation if the --activated class is present', - () => { - const {root, component, mockFoundation} = setupTest(); - (root.querySelector('.mdc-list-item') as HTMLElement) - .classList.add( - MDCListFoundation.cssClasses.LIST_ITEM_ACTIVATED_CLASS); - component.initializeListType(); - expect(mockFoundation.setUseActivatedClass).toHaveBeenCalledWith(true); - expect(mockFoundation.setUseActivatedClass).toHaveBeenCalledTimes(1); - expect(mockFoundation.setSingleSelection).toHaveBeenCalledWith(true); - expect(mockFoundation.setSingleSelection).toHaveBeenCalledTimes(1); - }); - it('#initializeListType populates selectedIndex based on preselected checkbox items', () => { const {root, component, mockFoundation} = setupTest(); diff --git a/packages/mdc-list/test/foundation.test.ts b/packages/mdc-list/test/foundation.test.ts index c8c268de576..68ee2a5d46e 100644 --- a/packages/mdc-list/test/foundation.test.ts +++ b/packages/mdc-list/test/foundation.test.ts @@ -198,11 +198,12 @@ describe('MDCListFoundation', () => { .toHaveBeenCalledWith(2, 'tabindex', '0'); }); - it('#handleFocusOut sets tabindex=0 to first item when focus leaves single selection list that has no ' + - 'selection', + it('#handleFocusOut sets tabindex=0 to previously focused item when focus' + + 'leaves list that has no selection', () => { const {foundation, mockAdapter} = setupTest(); + foundation['focusedItemIndex'] = 3; foundation.setSingleSelection(true); mockAdapter.getListItemCount.and.returnValue(4); mockAdapter.isFocusInsideList.and.returnValue(false); @@ -212,7 +213,7 @@ describe('MDCListFoundation', () => { foundation.handleFocusOut(event, 3); jasmine.clock().tick(1); expect(mockAdapter.setAttributeForElementIndex) - .toHaveBeenCalledWith(0, 'tabindex', '0'); + .toHaveBeenCalledWith(3, 'tabindex', '0'); }); it('#handleFocusOut does not set tabindex=0 to selected list item when focus moves to next list item.', @@ -689,7 +690,8 @@ describe('MDCListFoundation', () => { expect(preventDefault).toHaveBeenCalledTimes(1); expect(mockAdapter.setAttributeForElementIndex) .toHaveBeenCalledWith(0, strings.ARIA_SELECTED, 'true'); - expect(mockAdapter.setAttributeForElementIndex).toHaveBeenCalledTimes(1); + expect(mockAdapter.setAttributeForElementIndex) + .toHaveBeenCalledWith(0, 'tabindex', '0'); }); it('#handleKeydown does not select the list item when' + @@ -731,7 +733,9 @@ describe('MDCListFoundation', () => { expect(preventDefault).toHaveBeenCalledTimes(1); expect(mockAdapter.setAttributeForElementIndex) .toHaveBeenCalledWith(0, strings.ARIA_SELECTED, 'true'); - expect(mockAdapter.setAttributeForElementIndex).toHaveBeenCalledTimes(1); + expect(mockAdapter.setAttributeForElementIndex) + .toHaveBeenCalledWith(0, 'tabindex', '0'); + ; }); it('#handleKeydown space key when singleSelection=true does not select an element is isRootListItem=false', @@ -793,7 +797,8 @@ describe('MDCListFoundation', () => { expect(preventDefault).toHaveBeenCalledTimes(2); expect(mockAdapter.setAttributeForElementIndex) .toHaveBeenCalledWith(0, strings.ARIA_SELECTED, 'true'); - expect(mockAdapter.setAttributeForElementIndex).toHaveBeenCalledTimes(1); + expect(mockAdapter.setAttributeForElementIndex) + .not.toHaveBeenCalledWith(0, strings.ARIA_SELECTED, 'false'); }); it('#handleKeydown space key is triggered 2x when singleSelection is true on second ' + @@ -815,7 +820,8 @@ describe('MDCListFoundation', () => { expect(preventDefault).toHaveBeenCalledTimes(2); expect(mockAdapter.setAttributeForElementIndex) .toHaveBeenCalledWith(1, strings.ARIA_SELECTED, 'true'); - expect(mockAdapter.setAttributeForElementIndex).toHaveBeenCalledTimes(1); + expect(mockAdapter.setAttributeForElementIndex) + .toHaveBeenCalledWith(0, 'tabindex', '-1'); }); it('#handleKeydown bail out early if event origin doesnt have a mdc-list-item ancestor from the current list', @@ -991,7 +997,7 @@ describe('MDCListFoundation', () => { (args: any) => JSON.stringify(args) == JSON.stringify([0, 'tabindex', '0'])) .length) - .toEqual(2); + .toEqual(1); }); it('#handleClick when toggleCheckbox=false does not change the checkbox state', @@ -1073,6 +1079,35 @@ describe('MDCListFoundation', () => { .not.toHaveBeenCalledWith(1, jasmine.anything()); }); + it('#setSingleSelection true with --selected item initializes list state' + + ' to correct selection', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.getListItemCount.and.returnValue(3); + mockAdapter.listItemAtIndexHasClass + .withArgs(2, cssClasses.LIST_ITEM_SELECTED_CLASS) + .and.returnValue(true); + foundation.setSingleSelection(true); + + expect(foundation.getSelectedIndex()).toEqual(2); + }); + + it('#setSingleSelection true with --activated item initializes list state' + + ' to correct selection and causes further selections to use activation', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.getListItemCount.and.returnValue(3); + mockAdapter.listItemAtIndexHasClass + .withArgs(2, cssClasses.LIST_ITEM_ACTIVATED_CLASS) + .and.returnValue(true); + foundation.setSingleSelection(true); + + expect(foundation.getSelectedIndex()).toEqual(2); + foundation.setSelectedIndex(1); + expect(mockAdapter.addClassForElementIndex) + .toHaveBeenCalledWith(1, cssClasses.LIST_ITEM_ACTIVATED_CLASS); + }); + it('#setUseActivatedClass causes setSelectedIndex to use the --activated class', () => { const {foundation, mockAdapter} = setupTest(); @@ -1177,7 +1212,13 @@ describe('MDCListFoundation', () => { .not.toHaveBeenCalledWith(2, strings.ARIA_CURRENT, 'false'); expect(mockAdapter.setAttributeForElementIndex) .toHaveBeenCalledWith(2, strings.ARIA_CURRENT, 'page'); - expect(mockAdapter.setAttributeForElementIndex).toHaveBeenCalledTimes(1); + + expect(mockAdapter.setAttributeForElementIndex.calls.allArgs() + .filter( + (args: any) => JSON.stringify(args) == + JSON.stringify([2, strings.ARIA_CURRENT, 'page'])) + .length) + .toEqual(1); }); it('#setSelectedIndex should set aria-selected as default option in the absence of aria-selected on pre-selected ' + @@ -1194,7 +1235,12 @@ describe('MDCListFoundation', () => { .not.toHaveBeenCalledWith(2, jasmine.any(String), 'false'); expect(mockAdapter.setAttributeForElementIndex) .toHaveBeenCalledWith(2, strings.ARIA_SELECTED, 'true'); - expect(mockAdapter.setAttributeForElementIndex).toHaveBeenCalledTimes(1); + expect(mockAdapter.setAttributeForElementIndex.calls.allArgs() + .filter( + (args: any) => JSON.stringify(args) == + JSON.stringify([2, strings.ARIA_SELECTED, 'true'])) + .length) + .toEqual(1); }); it('#setSelectedIndex sets aria-current="false" to previously selected index and sets aria-current without any token' + diff --git a/packages/mdc-menu/component.ts b/packages/mdc-menu/component.ts index cd351c1b4a1..bc2289fbacc 100644 --- a/packages/mdc-menu/component.ts +++ b/packages/mdc-menu/component.ts @@ -21,12 +21,16 @@ * THE SOFTWARE. */ +// TODO(b/152410470): Remove trailing underscores from private properties +// tslint:disable:strip-private-property-underscore + import {MDCComponent} from '@material/base/component'; import {CustomEventListener, SpecificEventListener} from '@material/base/types'; import {closest} from '@material/dom/ponyfill'; import {MDCList, MDCListFactory} from '@material/list/component'; +import {numbers as listConstants} from '@material/list/constants'; import {MDCListFoundation} from '@material/list/foundation'; -import {MDCListActionEvent} from '@material/list/types'; +import {MDCListActionEvent, MDCListIndex} from '@material/list/types'; import {MDCMenuSurface, MDCMenuSurfaceFactory} from '@material/menu-surface/component'; import {Corner} from '@material/menu-surface/constants'; import {MDCMenuSurfaceFoundation} from '@material/menu-surface/foundation'; @@ -172,6 +176,38 @@ export class MDCMenu extends MDCComponent { return this.list_ ? this.list_.listElements : []; } + /** + * Turns on/off the underlying list's single selection mode. Used mainly + * by select menu. + * + * @param singleSelection Whether to enable single selection mode. + */ + set singleSelection(singleSelection: boolean) { + if (this.list_) { + this.list_.singleSelection = singleSelection; + } + } + + /** + * Retrieves the selected index. Only applicable to select menus. + * @return The selected index, which is a number for single selection and + * radio lists, and an array of numbers for checkbox lists. + */ + get selectedIndex(): MDCListIndex { + return this.list_ ? this.list_.selectedIndex : listConstants.UNSET_INDEX; + } + + /** + * Sets the selected index of the list. Only applicable to select menus. + * @param index The selected index, which is a number for single selection and + * radio lists, and an array of numbers for checkbox lists. + */ + set selectedIndex(index: MDCListIndex) { + if (this.list_) { + this.list_.selectedIndex = index; + } + } + set quickOpen(quickOpen: boolean) { this.menuSurface_.quickOpen = quickOpen; } diff --git a/packages/mdc-select/adapter.ts b/packages/mdc-select/adapter.ts index 6ae29d74765..fd0a938fb32 100644 --- a/packages/mdc-select/adapter.ts +++ b/packages/mdc-select/adapter.ts @@ -202,6 +202,16 @@ export interface MDCSelectAdapter { */ getMenuItemAttr(menuItem: Element, attr: string): string | null; + /** + * Returns the selected index. + */ + getSelectedIndex(): number; + + /** + * Sets the selected index in the menu. + */ + setSelectedIndex(index: number): void; + /** * Adds the class name on the menu item at the given index. */ diff --git a/packages/mdc-select/component.ts b/packages/mdc-select/component.ts index 094658a000f..42dd7b98252 100644 --- a/packages/mdc-select/component.ts +++ b/packages/mdc-select/component.ts @@ -318,6 +318,7 @@ export class MDCSelect extends MDCComponent { this.menuElement = this.root.querySelector(strings.MENU_SELECTOR)!; this.menu = menuFactory(this.menuElement); this.menu.hasTypeahead = true; + this.menu.singleSelection = true; } private createRipple(): MDCRipple { @@ -379,6 +380,13 @@ export class MDCSelect extends MDCComponent { setMenuWrapFocus: (wrapFocus: boolean) => { this.menu.wrapFocus = wrapFocus; }, + getSelectedIndex: () => { + const index = this.menu.selectedIndex; + return index instanceof Array ? index[0] : index; + }, + setSelectedIndex: (index: number) => { + this.menu.selectedIndex = index; + }, setAttributeAtIndex: (index: number, attributeName: string, attributeValue: string) => { this.menu.items[index].setAttribute(attributeName, attributeValue); diff --git a/packages/mdc-select/foundation.ts b/packages/mdc-select/foundation.ts index 4363708bea9..6cfecb95ce1 100644 --- a/packages/mdc-select/foundation.ts +++ b/packages/mdc-select/foundation.ts @@ -56,6 +56,8 @@ export class MDCSelectFoundation extends MDCFoundation { activateBottomLine: () => undefined, deactivateBottomLine: () => undefined, getSelectedMenuItem: () => null, + getSelectedIndex: () => -1, + setSelectedIndex: () => undefined, hasLabel: () => false, floatLabel: () => undefined, getLabelWidth: () => 0, @@ -95,8 +97,6 @@ export class MDCSelectFoundation extends MDCFoundation { private readonly leadingIcon: MDCSelectIconFoundation|undefined; private readonly helperText: MDCSelectHelperTextFoundation|undefined; - // Index of the currently selected menu item. - private selectedIndex: number = numbers.UNSET_INDEX; // VALUE_ATTR values of the menu items. private menuItemValues: string[] = []; // Disabled state @@ -124,26 +124,34 @@ export class MDCSelectFoundation extends MDCFoundation { /** Returns the index of the currently selected menu item, or -1 if none. */ getSelectedIndex(): number { - return this.selectedIndex; + return this.adapter.getSelectedIndex(); } - setSelectedIndex(index: number, closeMenu = false) { + setSelectedIndex(index: number, closeMenu = false, skipNotify = false) { if (index >= this.adapter.getMenuItemCount()) { return; } - this.removeSelectionAtIndex(this.selectedIndex); - this.setSelectionAtIndex(index); + if (index === numbers.UNSET_INDEX) { + this.adapter.setSelectedText(''); + } else { + this.adapter.setSelectedText( + this.adapter.getMenuItemTextAtIndex(index).trim()); + } + + this.adapter.setSelectedIndex(index); if (closeMenu) { this.adapter.closeMenu(); } - this.handleChange(); + if (!skipNotify) { + this.handleChange(); + } } setValue(value: string) { - const index = this.menuItemValues.indexOf(value); + const index = this.adapter.getMenuItemValues().indexOf(value); this.setSelectedIndex(index); } @@ -224,16 +232,18 @@ export class MDCSelectFoundation extends MDCFoundation { layoutOptions() { this.menuItemValues = this.adapter.getMenuItemValues(); const selectedIndex = this.menuItemValues.indexOf(this.getValue()); - this.setSelectionAtIndex(selectedIndex); + this.setSelectedIndex( + selectedIndex, /** closeMenu */ false, /** skipNotify */ true); } handleMenuOpened() { - if (this.menuItemValues.length === 0) { + if (this.adapter.getMenuItemValues().length === 0) { return; } // Menu should open to the last selected element, should open to first menu item otherwise. - const focusItemIndex = this.selectedIndex >= 0 ? this.selectedIndex : 0; + const selectedIndex = this.getSelectedIndex(); + const focusItemIndex = selectedIndex >= 0 ? selectedIndex : 0; this.adapter.focusMenuItemAtIndex(focusItemIndex); } @@ -322,7 +332,7 @@ export class MDCSelectFoundation extends MDCFoundation { isSpace && this.adapter.isTypeaheadInProgress()) { const key = isSpace ? ' ' : event.key; const typeaheadNextIndex = - this.adapter.typeaheadMatchItem(key, this.selectedIndex); + this.adapter.typeaheadMatchItem(key, this.getSelectedIndex()); if (typeaheadNextIndex >= 0) { this.setSelectedIndex(typeaheadNextIndex); } @@ -335,11 +345,12 @@ export class MDCSelectFoundation extends MDCFoundation { } // Increment/decrement index as necessary and open menu. - if (arrowUp && this.selectedIndex > 0) { - this.setSelectedIndex(this.selectedIndex - 1); + if (arrowUp && this.getSelectedIndex() > 0) { + this.setSelectedIndex(this.getSelectedIndex() - 1); } else if ( - arrowDown && this.selectedIndex < this.adapter.getMenuItemCount() - 1) { - this.setSelectedIndex(this.selectedIndex + 1); + arrowDown && + this.getSelectedIndex() < this.adapter.getMenuItemCount() - 1) { + this.setSelectedIndex(this.getSelectedIndex() + 1); } this.openMenu(); @@ -407,8 +418,8 @@ export class MDCSelectFoundation extends MDCFoundation { !this.adapter.hasClass(cssClasses.DISABLED)) { // See notes for required attribute under https://www.w3.org/TR/html52/sec-forms.html#the-select-element // TL;DR: Invalid if no index is selected, or if the first index is selected and has an empty value. - return this.selectedIndex !== numbers.UNSET_INDEX && - (this.selectedIndex !== 0 || Boolean(this.getValue())); + return this.getSelectedIndex() !== numbers.UNSET_INDEX && + (this.getSelectedIndex() !== 0 || Boolean(this.getValue())); } return this.customValidity; } @@ -436,8 +447,8 @@ export class MDCSelectFoundation extends MDCFoundation { this.adapter.setMenuWrapFocus(false); this.setDisabled(this.adapter.hasClass(cssClasses.DISABLED)); - this.layoutOptions(); this.layout(); + this.layoutOptions(); } /** @@ -456,28 +467,6 @@ export class MDCSelectFoundation extends MDCFoundation { } } } - - private setSelectionAtIndex(index: number) { - this.selectedIndex = index; - - if (index === numbers.UNSET_INDEX) { - this.adapter.setSelectedText(''); - return; - } - - this.adapter.setSelectedText( - this.adapter.getMenuItemTextAtIndex(index).trim()); - this.adapter.addClassAtIndex(index, cssClasses.SELECTED_ITEM_CLASS); - this.adapter.setAttributeAtIndex(index, strings.ARIA_SELECTED_ATTR, 'true'); - } - - private removeSelectionAtIndex(index: number) { - if (index !== numbers.UNSET_INDEX) { - this.adapter.removeClassAtIndex(index, cssClasses.SELECTED_ITEM_CLASS); - this.adapter.setAttributeAtIndex( - index, strings.ARIA_SELECTED_ATTR, 'false'); - } - } } // tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier. diff --git a/packages/mdc-select/test/component.test.ts b/packages/mdc-select/test/component.test.ts index 4ef6e4857b8..5b4e580714e 100644 --- a/packages/mdc-select/test/component.test.ts +++ b/packages/mdc-select/test/component.test.ts @@ -361,15 +361,16 @@ describe('MDCSelect', () => { expect(mockFoundation.layout).toHaveBeenCalled(); }); - it('#layoutOptions calls foundation.layoutOptions', () => { + it('#layoutOptions calls foundation.layoutOptions and menu.layout', () => { const hasMockFoundation = true; const hasMockMenu = true; const hasOutline = false; const hasLabel = true; - const {component, mockFoundation} = + const {component, mockFoundation, mockMenu} = setupTest(hasOutline, hasLabel, hasMockFoundation, hasMockMenu); component.layoutOptions(); expect(mockFoundation.layoutOptions).toHaveBeenCalled(); + expect(mockMenu.layout).toHaveBeenCalled(); }); it('#set useDefaultValidation forwards to foundation', () => { diff --git a/packages/mdc-select/test/foundation.test.ts b/packages/mdc-select/test/foundation.test.ts index 6d83dcc4715..42da40090de 100644 --- a/packages/mdc-select/test/foundation.test.ts +++ b/packages/mdc-select/test/foundation.test.ts @@ -80,6 +80,8 @@ describe('MDCSelectFoundation', () => { 'removeClassAtIndex', 'isTypeaheadInProgress', 'typeaheadMatchItem', + 'getSelectedIndex', + 'setSelectedIndex', ]); }); @@ -209,7 +211,7 @@ describe('MDCSelectFoundation', () => { it('#handleMenuOpened focuses last selected element', () => { const {foundation, mockAdapter} = setupTest(); foundation.init(); - (foundation as any).selectedIndex = 2; + mockAdapter.getSelectedIndex.and.returnValue(2); foundation.handleMenuOpened(); expect(mockAdapter.focusMenuItemAtIndex).toHaveBeenCalledWith(2); expect(mockAdapter.focusMenuItemAtIndex).toHaveBeenCalledTimes(1); @@ -378,7 +380,7 @@ describe('MDCSelectFoundation', () => { mockAdapter.getMenuItemAttr .withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue('foo'); - (foundation as any).selectedIndex = 0; + mockAdapter.getSelectedIndex.and.returnValue(0); foundation.handleBlur(); expect(helperText.setValidity).toHaveBeenCalledWith(true); expect(helperText.setValidity).toHaveBeenCalledTimes(1); @@ -549,19 +551,22 @@ describe('MDCSelectFoundation', () => { mockAdapter.getMenuItemAttr .withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue('two'); + mockAdapter.getSelectedIndex.and.returnValue(2); foundation.init(); const event = {key: 'ArrowUp', preventDefault} as any; foundation.handleKeydown(event); - expect(foundation.getSelectedIndex()).toEqual(1); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(1); + mockAdapter.getSelectedIndex.and.returnValue(1); foundation['isMenuOpen'] = false; event.key = ''; event.keyCode = 38; // Up foundation.handleKeydown(event); - expect(foundation.getSelectedIndex()).toEqual(0); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(0); // Further ArrowUps should be no-ops once we're at first item + mockAdapter.getSelectedIndex.and.returnValue(0); foundation['isMenuOpen'] = false; event.key = 'ArrowUp'; event.keyCode = undefined; @@ -571,7 +576,6 @@ describe('MDCSelectFoundation', () => { event.keyCode = 38; // Up foundation.handleKeydown(event); - expect(foundation.getSelectedIndex()).toEqual(0); expect(mockAdapter.notifyChange).toHaveBeenCalledTimes(2); }); @@ -586,19 +590,22 @@ describe('MDCSelectFoundation', () => { mockAdapter.getMenuItemAttr .withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue('zero'); + mockAdapter.getSelectedIndex.and.returnValue(0); foundation.init(); const event = {key: 'ArrowDown', preventDefault} as any; foundation.handleKeydown(event); - expect(foundation.getSelectedIndex()).toEqual(1); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(1); + mockAdapter.getSelectedIndex.and.returnValue(1); foundation['isMenuOpen'] = false; event.key = ''; event.keyCode = 40; // Down foundation.handleKeydown(event); - expect(foundation.getSelectedIndex()).toEqual(2); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(2); // Further ArrowDowns should be no-ops once we're at last item + mockAdapter.getSelectedIndex.and.returnValue(2); foundation['isMenuOpen'] = false; event.key = 'ArrowDown'; event.keyCode = undefined; @@ -608,7 +615,6 @@ describe('MDCSelectFoundation', () => { event.keyCode = 40; // Down foundation.handleKeydown(event); - expect(foundation.getSelectedIndex()).toEqual(2); expect(mockAdapter.notifyChange).toHaveBeenCalledTimes(2); }); @@ -714,23 +720,25 @@ describe('MDCSelectFoundation', () => { it('#layoutOptions reinitializes selected nonempty value', () => { const {foundation, mockAdapter} = setupTest(); foundation.init(); + mockAdapter.getMenuItemCount.and.returnValue(3); mockAdapter.getMenuItemValues.and.returnValue(['zero', 'one', 'two']); mockAdapter.getMenuItemAttr.withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue('two'); foundation.layoutOptions(); - expect(foundation.getSelectedIndex()).toEqual(2); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(2); }); it('#layoutOptions reinitializes selected empty value', () => { const {foundation, mockAdapter} = setupTest(); foundation.init(); + mockAdapter.getMenuItemCount.and.returnValue(3); mockAdapter.getMenuItemValues.and.returnValue(['', 'one', 'two']); mockAdapter.getMenuItemAttr.withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue(''); foundation.layoutOptions(); - expect(foundation.getSelectedIndex()).toEqual(0); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(0); }); it('#setLeadingIconAriaLabel sets the aria-label of the leading icon element', @@ -786,26 +794,13 @@ describe('MDCSelectFoundation', () => { const {foundation, mockAdapter} = setupTest(); foundation.setSelectedIndex(1); - expect(mockAdapter.addClassAtIndex) - .toHaveBeenCalledWith(1, cssClasses.SELECTED_ITEM_CLASS); - expect(mockAdapter.setAttributeAtIndex) - .toHaveBeenCalledWith(1, strings.ARIA_SELECTED_ATTR, 'true'); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(1); foundation.setSelectedIndex(0); - expect(mockAdapter.removeClassAtIndex) - .toHaveBeenCalledWith(1, cssClasses.SELECTED_ITEM_CLASS); - expect(mockAdapter.setAttributeAtIndex) - .toHaveBeenCalledWith(1, strings.ARIA_SELECTED_ATTR, 'false'); - expect(mockAdapter.addClassAtIndex) - .toHaveBeenCalledWith(0, cssClasses.SELECTED_ITEM_CLASS); - expect(mockAdapter.setAttributeAtIndex) - .toHaveBeenCalledWith(0, strings.ARIA_SELECTED_ATTR, 'true'); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(0); foundation.setSelectedIndex(-1); - expect(mockAdapter.removeClassAtIndex) - .toHaveBeenCalledWith(0, cssClasses.SELECTED_ITEM_CLASS); - expect(mockAdapter.setAttributeAtIndex) - .toHaveBeenCalledWith(0, strings.ARIA_SELECTED_ATTR, 'false'); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(-1); expect(mockAdapter.notifyChange).toHaveBeenCalledTimes(3); }); @@ -814,10 +809,7 @@ describe('MDCSelectFoundation', () => { const {foundation, mockAdapter} = setupTest(); foundation.init(); foundation.setValue('bar'); - expect(mockAdapter.addClassAtIndex) - .toHaveBeenCalledWith(1, cssClasses.SELECTED_ITEM_CLASS); - expect(mockAdapter.setAttributeAtIndex) - .toHaveBeenCalledWith(1, strings.ARIA_SELECTED_ATTR, 'true'); + expect(mockAdapter.setSelectedIndex).toHaveBeenCalledWith(1); expect(mockAdapter.notifyChange).toHaveBeenCalledTimes(1); }); @@ -848,7 +840,7 @@ describe('MDCSelectFoundation', () => { mockAdapter.hasClass.withArgs(cssClasses.REQUIRED).and.returnValue(true); mockAdapter.hasClass.withArgs(cssClasses.DISABLED) .and.returnValue(false); - (foundation as any).selectedIndex = -1; + mockAdapter.getSelectedIndex.and.returnValue(-1); expect(foundation.isValid()).toBe(false); }); @@ -863,7 +855,7 @@ describe('MDCSelectFoundation', () => { mockAdapter.getMenuItemAttr .withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue(''); - (foundation as any).selectedIndex = 0; + mockAdapter.getSelectedIndex.and.returnValue(0); expect(foundation.isValid()).toBe(false); }); @@ -877,7 +869,7 @@ describe('MDCSelectFoundation', () => { mockAdapter.getMenuItemAttr .withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue('foo'); - (foundation as any).selectedIndex = 0; + mockAdapter.getSelectedIndex.and.returnValue(0); expect(foundation.isValid()).toBe(true); }); @@ -889,7 +881,7 @@ describe('MDCSelectFoundation', () => { foundation.setUseDefaultValidation(false); foundation.setValid(false); - (foundation as any).selectedIndex = 2; + mockAdapter.getSelectedIndex.and.returnValue(2); expect(foundation.isValid()).toBe(false); }); @@ -903,7 +895,7 @@ describe('MDCSelectFoundation', () => { foundation.setUseDefaultValidation(false); foundation.setValid(true); - (foundation as any).selectedIndex = -1; + mockAdapter.getSelectedIndex.and.returnValue(-1); expect(foundation.isValid()).toBe(true); }); @@ -921,7 +913,7 @@ describe('MDCSelectFoundation', () => { mockAdapter.getMenuItemAttr .withArgs(jasmine.anything(), strings.VALUE_ATTR) .and.returnValue(''); - (foundation as any).selectedIndex = 0; + mockAdapter.getSelectedIndex.and.returnValue(0); expect(foundation.isValid()).toBe(true); }); @@ -980,13 +972,6 @@ describe('MDCSelectFoundation', () => { .toHaveBeenCalledWith(jasmine.anything()); }); - it('#init calls layoutOptions', () => { - const {foundation} = setupTest(); - foundation.layoutOptions = jasmine.createSpy(''); - foundation.init(); - expect(foundation.layoutOptions).toHaveBeenCalledTimes(1); - }); - it('#init emits no change events when value is preselected', () => { const {foundation, mockAdapter} = setupTest(); mockAdapter.getMenuItemAttr.withArgs(jasmine.anything(), strings.VALUE_ATTR)