From be070a41dad9e50b045dccdb1d2094b15624e554 Mon Sep 17 00:00:00 2001 From: Abhinay Omkar Date: Sat, 5 Jan 2019 18:15:06 +0530 Subject: [PATCH] fix(list): Accept array of index for selectedIndex API (#4124) BREAKING CHANGE: Introduced new adapter `isFocusInsideList` for MDC List for improved accessibility. --- packages/mdc-list/README.md | 14 +- packages/mdc-list/adapter.js | 5 + packages/mdc-list/constants.js | 7 +- packages/mdc-list/foundation.js | 359 +++++++++++++++++--------- packages/mdc-list/index.js | 38 ++- test/unit/mdc-list/foundation.test.js | 313 +++++++++++++++++----- test/unit/mdc-list/mdc-list.test.js | 34 +++ 7 files changed, 572 insertions(+), 198 deletions(-) diff --git a/packages/mdc-list/README.md b/packages/mdc-list/README.md index a6a78250a13..c4b0bfe1760 100644 --- a/packages/mdc-list/README.md +++ b/packages/mdc-list/README.md @@ -359,6 +359,8 @@ When rendering list with checkbox items all pre-selected list items should conta ``` +The `selectedIndex` (that proxies foundation's `setSelectedState()`) accepts list of indexes in array format for list with checkbox items to set the selection state. It overwrites the current state with new selected state. + ## Style Customization ### CSS Classes @@ -509,6 +511,7 @@ Method Signature | Description `hasCheckboxAtIndex(index: number) => boolean` | Returns true if checkbox is present at given list item index. `isCheckboxCheckedAtIndex(index: number) => boolean` | Returns true if checkbox inside a list item is checked. `setCheckedCheckboxOrRadioAtIndex(index: number, isChecked: boolean) => void` | Sets the checked status of checkbox or radio at given list item index. +`isFocusInsideList() => boolean` | Returns true if the current focused element is inside list root. ### `MDCListFoundation` @@ -517,13 +520,14 @@ Method Signature | Description `setWrapFocus(value: Boolean) => void` | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa. `setVerticalOrientation(value: Boolean) => void` | Sets the list to an orientation causing the keys used for navigation to change. `true` results in the Up/Down arrow keys being used. `false` results in the Left/Right arrow keys being used. `setSingleSelection(value: Boolean) => void` | Sets the list to be a selection list. Enables the `enter` and `space` keys for selecting/deselecting a list item. -`setSelectedIndex(index: Number) => void` | Toggles the `selected` state of the list item at index `index`. +`getSelectedIndex() => Index` | Gets the current selection state by returning selected index or list of indexes for checkbox based list. See [constants.js](./constants.js) for `Index` type definition. +`setSelectedIndex(index: Index) => void` | Sets the selection state to given index or list of indexes if it is checkbox based list. See [constants.js](./constants.js) for `Index` type definition. `setUseActivated(useActivated: boolean) => void` | Sets the selection logic to apply/remove the `mdc-list-item--activated` class. `handleFocusIn(evt: Event) => void` | Handles the changing of `tabindex` to `0` for all button and anchor elements when a list item receives focus. `handleFocusOut(evt: Event) => void` | Handles the changing of `tabindex` to `-1` for all button and anchor elements when a list item loses focus. `handleKeydown(evt: Event) => void` | Handles determining if a focus action should occur when a key event is triggered. `handleClick(evt: Event) => void` | Handles toggling the selected/deselected state for a list item when clicked. This method is only used by the single selection list. -`focusNextElement(index: Number) => void` | Handles focusing the next element using the current `index`. -`focusPrevElement(index: Number) => void` | Handles focusing the previous element using the current `index`. -`focusFirstElement() => void` | Handles focusing the first element in a list. -`focusLastElement() => void` | Handles focusing the last element in a list. +`focusNextElement(index: number) => number` | Handles focusing the next element using the current `index`. Returns focused element index. +`focusPrevElement(index: number) => number` | Handles focusing the previous element using the current `index`. Returns focused element index. +`focusFirstElement() => number` | Handles focusing the first element in a list. Returns focused element index. +`focusLastElement() => number` | Handles focusing the last element in a list. Returns focused element index. diff --git a/packages/mdc-list/adapter.js b/packages/mdc-list/adapter.js index 517b3004ccc..b64ce003c58 100644 --- a/packages/mdc-list/adapter.js +++ b/packages/mdc-list/adapter.js @@ -113,6 +113,11 @@ class MDCListAdapter { * @param {boolean} isChecked */ setCheckedCheckboxOrRadioAtIndex(index, isChecked) {} + + /** + * @return {boolean} Returns true when the current focused element is inside list root. + */ + isFocusInsideList() {} } export default MDCListAdapter; diff --git a/packages/mdc-list/constants.js b/packages/mdc-list/constants.js index a0125e9b4bf..3fce66270bd 100644 --- a/packages/mdc-list/constants.js +++ b/packages/mdc-list/constants.js @@ -36,6 +36,8 @@ const strings = { ARIA_SELECTED: 'aria-selected', ARIA_CHECKED: 'aria-checked', ARIA_CHECKED_RADIO_SELECTOR: '[role="radio"][aria-checked="true"]', + ARIA_ROLE_CHECKBOX_SELECTOR: '[role="checkbox"]', + ARIA_CHECKED_CHECKBOX_SELECTOR: '[role="checkbox"][aria-checked="true"]', RADIO_SELECTOR: 'input[type="radio"]:not(:disabled)', CHECKBOX_SELECTOR: 'input[type="checkbox"]:not(:disabled)', CHECKBOX_RADIO_SELECTOR: 'input[type="checkbox"]:not(:disabled), input[type="radio"]:not(:disabled)', @@ -47,4 +49,7 @@ const strings = { ENABLED_ITEMS_SELECTOR: '.mdc-list-item:not(.mdc-list-item--disabled)', }; -export {strings, cssClasses}; +/** @typedef {number|!Array} */ +let Index; + +export {strings, cssClasses, Index}; diff --git a/packages/mdc-list/foundation.js b/packages/mdc-list/foundation.js index 5956b1fd0c7..a6fbc6a939d 100644 --- a/packages/mdc-list/foundation.js +++ b/packages/mdc-list/foundation.js @@ -23,7 +23,7 @@ import MDCFoundation from '@material/base/foundation'; import MDCListAdapter from './adapter'; -import {strings, cssClasses} from './constants'; +import {strings, cssClasses, Index} from './constants'; // eslint-disable-line no-unused-vars const ELEMENTS_KEY_ALLOWED_IN = ['input', 'button', 'textarea', 'select']; @@ -58,6 +58,7 @@ class MDCListFoundation extends MDCFoundation { hasCheckboxAtIndex: () => {}, isCheckboxCheckedAtIndex: () => {}, setCheckedCheckboxOrRadioAtIndex: () => {}, + isFocusInsideList: () => {}, }); } @@ -66,16 +67,39 @@ class MDCListFoundation extends MDCFoundation { */ constructor(adapter) { super(Object.assign(MDCListFoundation.defaultAdapter, adapter)); - /** {boolean} */ + /** @private {boolean} */ this.wrapFocus_ = false; - /** {boolean} */ + + /** @private {boolean} */ this.isVertical_ = true; - /** {boolean} */ + + /** @private {boolean} */ this.isSingleSelectionList_ = false; - /** {number} */ + + /** @private {!Index} */ this.selectedIndex_ = -1; - /** {boolean} */ + + /** @private {number} */ + this.focusedItemIndex_ = -1; + + /** @private {boolean} */ this.useActivatedClass_ = false; + + /** @private {boolean} */ + this.isCheckboxList_ = false; + + /** @private {boolean} */ + this.isRadioList_ = false; + } + + layout() { + if (this.adapter_.getListItemCount() === 0) return; + + if (this.adapter_.hasCheckboxAtIndex(0)) { + this.isCheckboxList_ = true; + } else if (this.adapter_.hasRadioAtIndex(0)) { + this.isRadioList_ = true; + } } /** @@ -110,81 +134,22 @@ class MDCListFoundation extends MDCFoundation { this.useActivatedClass_ = useActivated; } - /** @param {number} index */ - setSelectedIndex(index) { - if (index < 0 || index >= this.adapter_.getListItemCount()) return; - - if (this.adapter_.hasCheckboxAtIndex(index)) { - this.setAriaAttributesForCheckbox_(index); - } else if (this.adapter_.hasRadioAtIndex(index)) { - this.setAriaAttributesForRadio_(index); - } else { - this.setAriaAttributesForSingleSelect_(index); - this.setClassNamesForSingleSelect_(index); - } - - if (this.selectedIndex_ >= 0 && this.selectedIndex_ !== index) { - this.adapter_.setAttributeForElementIndex(this.selectedIndex_, 'tabindex', -1); - } else if (this.selectedIndex_ === -1 && 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. - this.adapter_.setAttributeForElementIndex(0, 'tabindex', -1); - } - - this.adapter_.setAttributeForElementIndex(index, 'tabindex', 0); - - this.selectedIndex_ = index; - } - - /** - * @param {number} index - * @private - */ - setAriaAttributesForCheckbox_(index) { - const ariaAttributeValue = this.adapter_.isCheckboxCheckedAtIndex(index) ? 'true' : 'false'; - this.adapter_.setAttributeForElementIndex(index, strings.ARIA_CHECKED, ariaAttributeValue); + /** @return {!Index} */ + getSelectedIndex() { + return this.selectedIndex_; } - /** - * @param {number} index - * @private - */ - setAriaAttributesForRadio_(index) { - if (this.selectedIndex_ >= 0) { - this.adapter_.setAttributeForElementIndex(this.selectedIndex_, strings.ARIA_CHECKED, 'false'); - } - - this.adapter_.setAttributeForElementIndex(index, strings.ARIA_CHECKED, 'true'); - } - - /** - * @param {number} index - * @private - */ - setAriaAttributesForSingleSelect_(index) { - if (this.selectedIndex_ >= 0 && this.selectedIndex_ !== index) { - this.adapter_.setAttributeForElementIndex(this.selectedIndex_, strings.ARIA_SELECTED, 'false'); - } - - this.adapter_.setAttributeForElementIndex(index, strings.ARIA_SELECTED, 'true'); - } - - /** - * @param {number} index - * @private - */ - setClassNamesForSingleSelect_(index) { - let selectedClassName = cssClasses.LIST_ITEM_SELECTED_CLASS; - - if (this.useActivatedClass_) { - selectedClassName = cssClasses.LIST_ITEM_ACTIVATED_CLASS; - } + /** @param {!Index} index */ + setSelectedIndex(index) { + if (!this.isIndexValid_(index)) return; - if (this.selectedIndex_ >= 0) { - this.adapter_.removeClassForElementIndex(this.selectedIndex_, selectedClassName); + if (this.isCheckboxList_) { + this.setCheckboxAtIndex_(/** @type {!Array} */ (index)); + } else if (this.isRadioList_) { + this.setRadioAtIndex_(/** @type {number} */ (index)); + } else { + this.setSingleSelectionAtIndex_(/** @type {number} */ (index)); } - - this.adapter_.addClassForElementIndex(index, selectedClassName); } /** @@ -207,6 +172,16 @@ class MDCListFoundation extends MDCFoundation { if (listItemIndex >= 0) { this.adapter_.setTabIndexForListItemChildren(listItemIndex, -1); } + + /** + * Between Focusout & Focusin some browsers do not have focus on any element. Setting a delay to wait till the focus + * is moved to next element. + */ + setTimeout(() => { + if (!this.adapter_.isFocusInsideList()) { + this.setTabindexToFirstSelectedItem_(); + } + }, 0); } /** @@ -226,6 +201,7 @@ class MDCListFoundation extends MDCFoundation { const isSpace = evt.key === 'Space' || evt.keyCode === 32; let currentIndex = this.adapter_.getFocusedElementIndex(); + let nextIndex = -1; if (currentIndex === -1) { currentIndex = listItemIndex; if (currentIndex < 0) { @@ -237,37 +213,34 @@ class MDCListFoundation extends MDCFoundation { if ((this.isVertical_ && arrowDown) || (!this.isVertical_ && arrowRight)) { this.preventDefaultEvent_(evt); - this.focusNextElement(currentIndex); + nextIndex = this.focusNextElement(currentIndex); } else if ((this.isVertical_ && arrowUp) || (!this.isVertical_ && arrowLeft)) { this.preventDefaultEvent_(evt); - this.focusPrevElement(currentIndex); + nextIndex = this.focusPrevElement(currentIndex); } else if (isHome) { this.preventDefaultEvent_(evt); - this.focusFirstElement(); + nextIndex = this.focusFirstElement(); } else if (isEnd) { this.preventDefaultEvent_(evt); - this.focusLastElement(); + nextIndex = this.focusLastElement(); } else if (isEnter || isSpace) { if (isRootListItem) { - if (this.isSingleSelectionList_) { - // Check if the space key was pressed on the list item or a child element. + if (this.isSelectableList_()) { + this.setSelectedIndexOnAction_(currentIndex); this.preventDefaultEvent_(evt); } - const hasCheckboxOrRadio = this.hasCheckboxOrRadioAtIndex_(listItemIndex); - if (hasCheckboxOrRadio) { - this.toggleCheckboxOrRadioAtIndex_(listItemIndex); - this.preventDefaultEvent_(evt); - } - - if (this.isSingleSelectionList_ || hasCheckboxOrRadio) { - this.setSelectedIndex(currentIndex); - } - // Explicitly activate links, since we're preventing default on Enter, and Space doesn't activate them. this.adapter_.followHref(currentIndex); } } + + this.focusedItemIndex_ = currentIndex; + + if (nextIndex >= 0) { + this.setTabindexAtIndex_(nextIndex); + this.focusedItemIndex_ = nextIndex; + } } /** @@ -278,13 +251,12 @@ class MDCListFoundation extends MDCFoundation { handleClick(index, toggleCheckbox) { if (index === -1) return; - if (toggleCheckbox) { - this.toggleCheckboxOrRadioAtIndex_(index); + if (this.isSelectableList_()) { + this.setSelectedIndexOnAction_(index, toggleCheckbox); } - if (this.isSingleSelectionList_ || this.hasCheckboxOrRadioAtIndex_(index)) { - this.setSelectedIndex(index); - } + this.setTabindexAtIndex_(index); + this.focusedItemIndex_ = index; } /** @@ -303,6 +275,7 @@ class MDCListFoundation extends MDCFoundation { /** * Focuses the next element on the list. * @param {number} index + * @return {number} */ focusNextElement(index) { const count = this.adapter_.getListItemCount(); @@ -312,15 +285,18 @@ class MDCListFoundation extends MDCFoundation { nextIndex = 0; } else { // Return early because last item is already focused. - return; + return index; } } this.adapter_.focusItemAtIndex(nextIndex); + + return nextIndex; } /** * Focuses the previous element on the list. * @param {number} index + * @return {number} */ focusPrevElement(index) { let prevIndex = index - 1; @@ -329,47 +305,200 @@ class MDCListFoundation extends MDCFoundation { prevIndex = this.adapter_.getListItemCount() - 1; } else { // Return early because first item is already focused. - return; + return index; } } this.adapter_.focusItemAtIndex(prevIndex); + + return prevIndex; } + /** + * @return {number} + */ focusFirstElement() { - if (this.adapter_.getListItemCount() > 0) { - this.adapter_.focusItemAtIndex(0); - } + this.adapter_.focusItemAtIndex(0); + return 0; } + /** + * @return {number} + */ focusLastElement() { const lastIndex = this.adapter_.getListItemCount() - 1; - if (lastIndex >= 0) { - this.adapter_.focusItemAtIndex(lastIndex); + this.adapter_.focusItemAtIndex(lastIndex); + return lastIndex; + } + + /** + * @param {number} index + * @private + */ + setSingleSelectionAtIndex_(index) { + let selectedClassName = cssClasses.LIST_ITEM_SELECTED_CLASS; + if (this.useActivatedClass_) { + selectedClassName = cssClasses.LIST_ITEM_ACTIVATED_CLASS; + } + + if (this.selectedIndex_ >= 0 && this.selectedIndex_ !== index) { + this.adapter_.removeClassForElementIndex(this.selectedIndex_, selectedClassName); + this.adapter_.setAttributeForElementIndex(this.selectedIndex_, strings.ARIA_SELECTED, 'false'); } + + this.adapter_.addClassForElementIndex(index, selectedClassName); + this.adapter_.setAttributeForElementIndex(index, strings.ARIA_SELECTED, 'true'); + + this.selectedIndex_ = index; } /** - * Toggles checkbox or radio at give index. Radio doesn't change the checked state if it is already checked. + * Toggles radio at give index. Radio doesn't change the checked state if it is already checked. * @param {number} index * @private */ - toggleCheckboxOrRadioAtIndex_(index) { - if (!this.hasCheckboxOrRadioAtIndex_(index)) return; + setRadioAtIndex_(index) { + this.adapter_.setCheckedCheckboxOrRadioAtIndex(index, true); + + if (this.selectedIndex_ >= 0) { + this.adapter_.setAttributeForElementIndex(this.selectedIndex_, strings.ARIA_CHECKED, 'false'); + } + + this.adapter_.setAttributeForElementIndex(index, strings.ARIA_CHECKED, 'true'); + + this.selectedIndex_ = index; + } + + /** + * @param {!Array} index + * @private + */ + setCheckboxAtIndex_(index) { + for (let i = 0; i < this.adapter_.getListItemCount(); i++) { + let isChecked = false; + if (index.indexOf(i) >= 0) { + isChecked = true; + } - let isChecked = true; - if (this.adapter_.hasCheckboxAtIndex(index)) { - isChecked = !this.adapter_.isCheckboxCheckedAtIndex(index); + this.adapter_.setCheckedCheckboxOrRadioAtIndex(i, isChecked); + this.adapter_.setAttributeForElementIndex(i, strings.ARIA_CHECKED, isChecked ? 'true' : 'false'); } - this.adapter_.setCheckedCheckboxOrRadioAtIndex(index, isChecked); + this.selectedIndex_ = index; } /** * @param {number} index - * @return {boolean} Return true if list item contains checkbox or radio input at given index. + * @private */ - hasCheckboxOrRadioAtIndex_(index) { - return this.adapter_.hasCheckboxAtIndex(index) || this.adapter_.hasRadioAtIndex(index); + setTabindexAtIndex_(index) { + if (this.focusedItemIndex_ === -1 && 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. + 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); + } + + /** + * @return {boolean} Return true if it is single selectin list, checkbox list or radio list. + * @private + */ + isSelectableList_() { + return this.isSingleSelectionList_ || this.isCheckboxList_ || this.isRadioList_; + } + + /** @private */ + setTabindexToFirstSelectedItem_() { + let targetIndex = 0; + + if (this.isSelectableList_()) { + if (typeof this.selectedIndex_ === 'number' && this.selectedIndex_ !== -1) { + targetIndex = this.selectedIndex_; + } else if (this.selectedIndex_ instanceof Array && this.selectedIndex_.length > 0) { + targetIndex = this.selectedIndex_.reduce((currentIndex, minIndex) => Math.min(currentIndex, minIndex)); + } + } + + this.setTabindexAtIndex_(targetIndex); + } + + /** + * @param {!Index} index + * @return {boolean} + * @private + */ + isIndexValid_(index) { + if (index instanceof Array) { + if (!this.isCheckboxList_) { + throw new Error('MDCListFoundation: Array of index is only supported for checkbox based list'); + } + + if (index.length === 0) { + return true; + } else { + return index.some((i) => this.isIndexInRange_(i)); + } + } else if (typeof index === 'number') { + if (this.isCheckboxList_) { + throw new Error('MDCListFoundation: Expected array of index for checkbox based list but got number: ' + index); + } + return this.isIndexInRange_(index); + } else { + return false; + } + } + + /** + * @param {number} index + * @return {boolean} + * @private + */ + isIndexInRange_(index) { + const listSize = this.adapter_.getListItemCount(); + return index >= 0 && index < listSize; + } + + /** + * @param {number} index + * @param {boolean=} toggleCheckbox + * @private + */ + setSelectedIndexOnAction_(index, toggleCheckbox = true) { + if (this.isCheckboxList_) { + this.toggleCheckboxAtIndex_(index, toggleCheckbox); + } else { + this.setSelectedIndex(index); + } + } + + /** + * @param {number} index + * @param {boolean} toggleCheckbox + * @private + */ + toggleCheckboxAtIndex_(index, toggleCheckbox) { + let isChecked = this.adapter_.isCheckboxCheckedAtIndex(index); + + if (toggleCheckbox) { + isChecked = !isChecked; + this.adapter_.setCheckedCheckboxOrRadioAtIndex(index, isChecked); + } + + this.adapter_.setAttributeForElementIndex(index, strings.ARIA_CHECKED, isChecked ? 'true' : 'false'); + + // If none of the checkbox items are selected and selectedIndex is not initialized then provide a default value. + if (this.selectedIndex_ === -1) { + this.selectedIndex_ = []; + } + + if (isChecked) { + this.selectedIndex_.push(index); + } else { + this.selectedIndex_ = this.selectedIndex_.filter((i) => i !== index); + } } } diff --git a/packages/mdc-list/index.js b/packages/mdc-list/index.js index 4f9a33f49dd..8fbc2e7a092 100644 --- a/packages/mdc-list/index.js +++ b/packages/mdc-list/index.js @@ -25,7 +25,7 @@ import MDCComponent from '@material/base/component'; import MDCListFoundation from './foundation'; import MDCListAdapter from './adapter'; import {matches} from '@material/dom/ponyfill'; -import {cssClasses, strings} from './constants'; +import {cssClasses, strings, Index} from './constants'; // eslint-disable-line no-unused-vars /** * @extends MDCComponent @@ -85,6 +85,8 @@ class MDCList extends MDCComponent { // Child button/a elements are not tabbable until the list item is focused. [].slice.call(this.root_.querySelectorAll(strings.FOCUSABLE_CHILD_ELEMENTS)) .forEach((ele) => ele.setAttribute('tabindex', -1)); + + this.foundation_.layout(); } /** @@ -158,21 +160,27 @@ class MDCList extends MDCComponent { this.foundation_.handleClick(index, toggleCheckbox); } + /** + * Initialize selectedIndex value based on pre-selected checkbox list items, single selection or radio. + */ initializeListType() { - // Pre-selected list item in single selected list or checked list item if list with radio input. - const preselectedElement = this.root_.querySelector(`.${cssClasses.LIST_ITEM_ACTIVATED_CLASS}, - .${cssClasses.LIST_ITEM_SELECTED_CLASS}, - ${strings.ARIA_CHECKED_RADIO_SELECTOR}`); + 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); - if (preselectedElement) { - if (preselectedElement.classList.contains(cssClasses.LIST_ITEM_ACTIVATED_CLASS)) { + if (checkboxListItems.length) { + const preselectedItems = this.root_.querySelectorAll(strings.ARIA_CHECKED_CHECKBOX_SELECTOR); + this.selectedIndex = [].map.call(preselectedItems, (listItem) => this.listElements.indexOf(listItem)); + } else if (singleSelectedListItem) { + if (singleSelectedListItem.classList.contains(cssClasses.LIST_ITEM_ACTIVATED_CLASS)) { this.foundation_.setUseActivatedClass(true); } this.singleSelection = true; - - // Automatically set selected index if single select list type or list with radio inputs. - this.selectedIndex = this.listElements.indexOf(preselectedElement); + this.selectedIndex = this.listElements.indexOf(singleSelectedListItem); + } else if (radioSelectedListItem) { + this.selectedIndex = this.listElements.indexOf(radioSelectedListItem); } } @@ -196,7 +204,12 @@ class MDCList extends MDCComponent { this.foundation_.setSingleSelection(isSingleSelectionList); } - /** @param {number} index */ + /** @return {!Index} */ + get selectedIndex() { + return this.foundation_.getSelectedIndex(); + } + + /** @param {!Index} index */ set selectedIndex(index) { this.foundation_.setSelectedIndex(index); } @@ -269,6 +282,9 @@ class MDCList extends MDCComponent { event.initEvent('change', true, true); toggleEl.dispatchEvent(event); }, + isFocusInsideList: () => { + return this.root_.contains(document.activeElement); + }, }))); } } diff --git a/test/unit/mdc-list/foundation.test.js b/test/unit/mdc-list/foundation.test.js index addad5aa62c..cbbba4196c1 100644 --- a/test/unit/mdc-list/foundation.test.js +++ b/test/unit/mdc-list/foundation.test.js @@ -29,6 +29,7 @@ import {verifyDefaultAdapter} from '../helpers/foundation'; import {setupFoundationTest} from '../helpers/setup'; import MDCListFoundation from '../../../packages/mdc-list/foundation'; import {strings, cssClasses} from '../../../packages/mdc-list/constants'; +import {install as installClock} from '../helpers/clock'; suite('MDCListFoundation'); @@ -46,6 +47,7 @@ test('defaultAdapter returns a complete adapter implementation', () => { 'removeAttributeForElementIndex', 'addClassForElementIndex', 'removeClassForElementIndex', 'focusItemAtIndex', 'setTabIndexForListItemChildren', 'followHref', 'hasRadioAtIndex', 'hasCheckboxAtIndex', 'isCheckboxCheckedAtIndex', 'setCheckedCheckboxOrRadioAtIndex', + 'isFocusInsideList', ]); }); @@ -65,6 +67,15 @@ Object.defineProperty(Array.prototype, 'contains', const setupTest = () => setupFoundationTest(MDCListFoundation); +test('#layout should bail out early when list is empty', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(0); + foundation.layout(); + + td.verify(mockAdapter.hasCheckboxAtIndex(0), {times: 0}); +}); + test('#handleFocusIn switches list item button/a elements to tabindex=0', () => { const {foundation, mockAdapter} = setupTest(); const target = {classList: ['mdc-list-item']}; @@ -127,6 +138,79 @@ test('#handleFocusOut does nothing if mdc-list-item is not on element or ancesto td.verify(mockAdapter.setTabIndexForListItemChildren(td.matchers.anything(), td.matchers.anything()), {times: 0}); }); +test('#handleFocusOut sets tabindex=0 to selected item when focus leaves single selection list', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(false); + td.when(mockAdapter.hasRadioAtIndex(0)).thenReturn(false); + foundation.setSingleSelection(true); + foundation.layout(); + + td.when(mockAdapter.isFocusInsideList()).thenReturn(false); + + foundation.setSelectedIndex(2); // Selected index values may not be in sequence. + const clock = installClock(); + const target = {classList: ['']}; + const event = {target}; + foundation.handleFocusOut(event, 3); + clock.runToFrame(); + td.verify(mockAdapter.setAttributeForElementIndex(2, 'tabindex', 0), {times: 1}); +}); + +test('#handleFocusOut sets tabindex=0 to first item when focus leaves single selection list that has no ' + + 'selection', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.setSingleSelection(true); + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.isFocusInsideList()).thenReturn(false); + + const clock = installClock(); + const target = {classList: ['']}; + const event = {target}; + foundation.handleFocusOut(event, 3); + clock.runToFrame(); + td.verify(mockAdapter.setAttributeForElementIndex(0, 'tabindex', 0), {times: 1}); +}); + +test('#handleFocusOut does not set tabindex=0 to selected list item when focus moves to next list item.', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setSingleSelection(true); + foundation.layout(); + + td.when(mockAdapter.isFocusInsideList()).thenReturn(true); + + foundation.setSelectedIndex(2); + const clock = installClock(); + const target = {classList: ['']}; + const event = {target}; + foundation.handleFocusOut(event, 3); + clock.runToFrame(); + td.verify(mockAdapter.setAttributeForElementIndex(2, 'tabindex', 0), {times: 0}); +}); + +test('#handleFocusOut sets tabindex=0 to first selected index when focus leaves checkbox based list', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + foundation.layout(); + + td.when(mockAdapter.isFocusInsideList()).thenReturn(false); + + foundation.setSelectedIndex([3, 2]); // Selected index values may not be in sequence. + const target = {classList: ['']}; + const event = {target}; + + const clock = installClock(); + foundation.handleFocusOut(event, 2); + clock.runToFrame(); + td.verify(mockAdapter.setAttributeForElementIndex(2, 'tabindex', 0), {times: 1}); +}); + test('#handleKeydown does nothing if the key is not used for navigation', () => { const {foundation, mockAdapter} = setupTest(); const preventDefault = td.func('preventDefault'); @@ -399,6 +483,7 @@ test('#handleKeydown space/enter key cause event.preventDefault when singleSelec td.when(mockAdapter.hasRadioAtIndex(0)).thenReturn(true); td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(false); foundation.setSingleSelection(false); + foundation.layout(); foundation.handleKeydown(event, true, 0); event.key = 'Enter'; foundation.handleKeydown(event, true, 0); @@ -519,7 +604,7 @@ test('#handleKeydown space key is triggered 2x when singleSelection does not un- td.verify(mockAdapter.removeAttributeForElementIndex(0, strings.ARIA_SELECTED), {times: 0}); }); -test('#handleKeydown space key is triggered when singleSelection is true on second ' + +test('#handleKeydown space key is triggered 2x when singleSelection is true on second ' + 'element updates first element tabindex', () => { const {foundation, mockAdapter} = setupTest(); const preventDefault = td.func('preventDefault'); @@ -530,56 +615,81 @@ test('#handleKeydown space key is triggered when singleSelection is true on seco td.when(mockAdapter.getListItemCount()).thenReturn(3); foundation.setSingleSelection(true); foundation.handleKeydown(event, true, 1); + foundation.handleKeydown(event, true, 1); - td.verify(preventDefault(), {times: 1}); - td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 1}); + td.verify(preventDefault(), {times: 2}); + td.verify(mockAdapter.setAttributeForElementIndex(1, strings.ARIA_SELECTED, 'true'), {times: 2}); }); -test('#handleKeydown space key is triggered 2x when singleSelection is true on second ' + - 'element updates first element tabindex', () => { +test('#handleKeydown bail out early if event origin doesnt have a mdc-list-item ancestor from the current list', () => { const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); const preventDefault = td.func('preventDefault'); - const target = {classList: ['mdc-list-item']}; - const event = {key: 'Space', target, preventDefault}; + const event = {key: 'ArrowDown', keyCode: 40, preventDefault}; + foundation.handleKeydown(event, /** isRootListItem */ true, /** listItemIndex */ -1); - td.when(mockAdapter.getFocusedElementIndex()).thenReturn(1); - td.when(mockAdapter.getListItemCount()).thenReturn(3); - foundation.setSingleSelection(true); - foundation.handleKeydown(event, true, 1); - foundation.handleKeydown(event, true, 1); + td.verify(preventDefault(), {times: 0}); +}); - td.verify(preventDefault(), {times: 2}); - td.verify(mockAdapter.setAttributeForElementIndex(1, strings.ARIA_SELECTED, 'true'), {times: 2}); - td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 2}); - td.verify(mockAdapter.setAttributeForElementIndex(0, 'tabindex', -1), {times: 1}); +test('#focusNextElement focuses next list item and returns that index', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + + assert.equal(3, foundation.focusNextElement(2)); + td.verify(mockAdapter.focusItemAtIndex(3), {times: 1}); }); -test('#handleKeydown space key is triggered and focused is moved to a different element', () => { +test('#focusNextElement focuses first list item when focus is on last list item when wrapFocus=true and returns that ' + + 'index', () => { const {foundation, mockAdapter} = setupTest(); - const preventDefault = td.func('preventDefault'); - const target = {classList: ['mdc-list-item']}; - const event = {key: 'Space', target, preventDefault}; - td.when(mockAdapter.getFocusedElementIndex()).thenReturn(1); - td.when(mockAdapter.getListItemCount()).thenReturn(3); - foundation.setSingleSelection(true); - foundation.handleKeydown(event, true, 1); - td.when(mockAdapter.getFocusedElementIndex()).thenReturn(2); - foundation.handleKeydown(event, true, 1); + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setWrapFocus(true); - td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', -1), {times: 1}); - td.verify(mockAdapter.setAttributeForElementIndex(2, 'tabindex', 0), {times: 1}); + assert.equal(0, foundation.focusNextElement(3)); + td.verify(mockAdapter.focusItemAtIndex(0), {times: 1}); }); -test('#handleKeydown bail out early if event origin doesnt have a mdc-list-item ancestor from the current list', () => { +test('#focusNextElement retains the focus on last item when wrapFocus=false and returns that index', () => { const {foundation, mockAdapter} = setupTest(); - td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); - const preventDefault = td.func('preventDefault'); - const event = {key: 'ArrowDown', keyCode: 40, preventDefault}; - foundation.handleKeydown(event, /** isRootListItem */ true, /** listItemIndex */ -1); + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setWrapFocus(false); - td.verify(preventDefault(), {times: 0}); + assert.equal(3, foundation.focusNextElement(3)); + td.verify(mockAdapter.focusItemAtIndex(td.matchers.isA(Number)), {times: 0}); +}); + +test('#focusPrevElement focuses previous list item and returns that index', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + + assert.equal(1, foundation.focusPrevElement(2)); + td.verify(mockAdapter.focusItemAtIndex(1), {times: 1}); +}); + +test('#focusPrevElement focuses last list item when focus is on first list item when wrapFocus=true and returns that ' + + 'index', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setWrapFocus(true); + + assert.equal(3, foundation.focusPrevElement(0)); + td.verify(mockAdapter.focusItemAtIndex(3), {times: 1}); +}); + +test('#focusPrevElement retains the focus on first list item when wrapFocus=false and returns that index', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setWrapFocus(false); + + assert.equal(0, foundation.focusPrevElement(0)); + td.verify(mockAdapter.focusItemAtIndex(td.matchers.isA(Number)), {times: 0}); }); test('#handleClick when singleSelection=false on a list item should not cause the list item to be selected', () => { @@ -589,7 +699,8 @@ test('#handleClick when singleSelection=false on a list item should not cause th td.when(mockAdapter.getListItemCount()).thenReturn(3); foundation.handleClick(1, false); - td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 0}); + td.verify(mockAdapter.addClassForElementIndex(1, cssClasses.LIST_ITEM_SELECTED_CLASS), {times: 0}); + td.verify(mockAdapter.addClassForElementIndex(1, cssClasses.LIST_ITEM_ACTIVATED_CLASS), {times: 0}); }); test('#handleClick when singleSelection=true on a list item should cause the list item to be selected', () => { @@ -626,6 +737,7 @@ test('#handleClick when singleSelection=true on the first element when already s const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(4); foundation.setSingleSelection(true); foundation.handleClick(0, false); foundation.handleClick(0, false); @@ -633,10 +745,24 @@ test('#handleClick when singleSelection=true on the first element when already s td.verify(mockAdapter.setAttributeForElementIndex(0, 'tabindex', 0), {times: 2}); }); +test('#handleClick when toggleCheckbox=false does not change the checkbox state', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.layout(); + foundation.handleClick(2, false); + + td.when(mockAdapter.isCheckboxCheckedAtIndex(2)).thenReturn(false); + td.verify(mockAdapter.setCheckedCheckboxOrRadioAtIndex(2, true), {times: 0}); +}); + test('#handleClick proxies to the adapter#setCheckedCheckboxOrRadioAtIndex if toggleCheckbox is true', () => { const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.hasRadioAtIndex(0)).thenReturn(true); + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.layout(); foundation.handleClick(0, true); td.verify(mockAdapter.setCheckedCheckboxOrRadioAtIndex(0, true), {times: 1}); @@ -645,8 +771,11 @@ test('#handleClick proxies to the adapter#setCheckedCheckboxOrRadioAtIndex if to test('#handleClick checks the checkbox at index if it is present on list item', () => { const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + foundation.layout(); + // Check - td.when(mockAdapter.hasCheckboxAtIndex(2)).thenReturn(true); td.when(mockAdapter.isCheckboxCheckedAtIndex(2)).thenReturn(false); foundation.handleClick(2, true); td.verify(mockAdapter.setCheckedCheckboxOrRadioAtIndex(2, true), {times: 1}); @@ -667,22 +796,6 @@ test('#handleClick bails out if checkbox or radio is not present and if toggleCh td.verify(mockAdapter.setCheckedCheckboxOrRadioAtIndex(1, td.matchers.anything()), {times: 0}); }); -test('#focusFirstElement is called when the list is empty does not focus an element', () => { - const {foundation, mockAdapter} = setupTest(); - td.when(mockAdapter.getListItemCount()).thenReturn(-1); - foundation.focusFirstElement(); - - td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0}); -}); - -test('#focusLastElement is called when the list is empty does not focus an element', () => { - const {foundation, mockAdapter} = setupTest(); - td.when(mockAdapter.getListItemCount()).thenReturn(-1); - foundation.focusLastElement(); - - td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0}); -}); - test('#setUseActivatedClass causes setSelectedIndex to use the --activated class', () => { const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.getListItemCount()).thenReturn(3); @@ -692,7 +805,7 @@ test('#setUseActivatedClass causes setSelectedIndex to use the --activated class td.verify(mockAdapter.addClassForElementIndex(1, cssClasses.LIST_ITEM_ACTIVATED_CLASS), {times: 1}); }); -test('#setSelectedIndex should bail out if not in the range', () => { +test('#setSelectedIndex should bail out early if not in the range', () => { const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.getListItemCount()).thenReturn(4); @@ -700,20 +813,29 @@ test('#setSelectedIndex should bail out if not in the range', () => { td.verify(mockAdapter.setAttributeForElementIndex(-1, 'tabindex', 0), {times: 0}); }); -test('#setSelectedIndex should set aria attributes on new index and should not change aria attributes on previous' + - ' selected index for checkbox based list', () => { +test('#setSelectedIndex should bail out early if index is string or invalid', () => { const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.getListItemCount()).thenReturn(4); - td.when(mockAdapter.hasCheckboxAtIndex(2)).thenReturn(true); + foundation.setSelectedIndex('some_random_input'); + td.verify(mockAdapter.setAttributeForElementIndex(-1, 'tabindex', 0), {times: 0}); +}); + +test('#setSelectedIndex should set aria checked true on new selected index and set aria checked false on previous ' + + 'selected index for checkbox based list', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + foundation.layout(); + td.when(mockAdapter.isCheckboxCheckedAtIndex(2)).thenReturn(true); - foundation.setSelectedIndex(2); + foundation.setSelectedIndex([2]); td.verify(mockAdapter.setAttributeForElementIndex(2, strings.ARIA_CHECKED, 'true'), {times: 1}); - td.when(mockAdapter.hasCheckboxAtIndex(3)).thenReturn(true); td.when(mockAdapter.isCheckboxCheckedAtIndex(3)).thenReturn(true); - foundation.setSelectedIndex(3); - td.verify(mockAdapter.setAttributeForElementIndex(2, strings.ARIA_CHECKED, 'false'), {times: 0}); + foundation.setSelectedIndex([3]); + td.verify(mockAdapter.setAttributeForElementIndex(2, strings.ARIA_CHECKED, 'false'), {times: 1}); }); test('#setSelectedIndex should set aria attributes on new index and should also set aria checked to false on previous' + @@ -721,16 +843,75 @@ test('#setSelectedIndex should set aria attributes on new index and should also const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.getListItemCount()).thenReturn(5); - td.when(mockAdapter.hasRadioAtIndex(3)).thenReturn(true); - td.when(mockAdapter.hasCheckboxAtIndex(3)).thenReturn(false); + td.when(mockAdapter.hasRadioAtIndex(0)).thenReturn(true); + foundation.layout(); + foundation.setSelectedIndex(3); td.verify(mockAdapter.setAttributeForElementIndex(3, strings.ARIA_CHECKED, 'true'), {times: 1}); - td.when(mockAdapter.getListItemCount()).thenReturn(5); - td.when(mockAdapter.hasRadioAtIndex(4)).thenReturn(true); - td.when(mockAdapter.hasCheckboxAtIndex(4)).thenReturn(false); foundation.setSelectedIndex(4); - td.verify(mockAdapter.setAttributeForElementIndex(4, strings.ARIA_CHECKED, 'true'), {times: 1}); td.verify(mockAdapter.setAttributeForElementIndex(3, strings.ARIA_CHECKED, 'false'), {times: 1}); }); + +test('#setSelectedIndex removes selected/activated class name and sets aria-selected to false from previously selected ' + + 'list item', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setSelectedIndex(2); + + foundation.setSelectedIndex(3); + td.verify(mockAdapter.removeClassForElementIndex(2, cssClasses.LIST_ITEM_SELECTED_CLASS), {times: 1}); + td.verify(mockAdapter.setAttributeForElementIndex(2, strings.ARIA_SELECTED, 'false'), {times: 1}); +}); + +test('#setSelectedIndex throws error when array of index is set on radio based list', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasRadioAtIndex(0)).thenReturn(true); + foundation.layout(); + + assert.throws(() => foundation.setSelectedIndex([0, 1, 2]), Error); +}); + +test('#setSelectedIndex throws error when single index number is set on multi-select checkbox based list', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + foundation.layout(); + + assert.throws(() => foundation.setSelectedIndex(2), Error); +}); + +test('#setSelectedIndex deselects all checkboxes when selected index is set to []', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + foundation.layout(); + + foundation.setSelectedIndex([]); + td.verify(mockAdapter.setCheckedCheckboxOrRadioAtIndex(td.matchers.anything(), false), {times: 4}); +}); + +test('#getSelectedIndex should be in-sync with setter method', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + foundation.setSelectedIndex(2); + assert.equal(2, foundation.getSelectedIndex()); +}); + +test('#getSelectedIndex should be in-sync with setter method for multi-select checkbox based list', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getListItemCount()).thenReturn(4); + td.when(mockAdapter.hasCheckboxAtIndex(0)).thenReturn(true); + foundation.layout(); + + foundation.setSelectedIndex([0, 2, 3]); + assert.deepEqual([0, 2, 3], foundation.getSelectedIndex()); +}); diff --git a/test/unit/mdc-list/mdc-list.test.js b/test/unit/mdc-list/mdc-list.test.js index 62d78f4debf..b8379d0d315 100644 --- a/test/unit/mdc-list/mdc-list.test.js +++ b/test/unit/mdc-list/mdc-list.test.js @@ -26,6 +26,7 @@ import {assert} from 'chai'; import td from 'testdouble'; import bel from 'bel'; import {MDCList, MDCListFoundation} from '../../../packages/mdc-list/index'; +import {cssClasses} from '../../../packages/mdc-list/constants'; function getFixture() { return bel` @@ -106,6 +107,32 @@ test('#initializeListType calls the foundation if the --activated class is prese td.verify(mockFoundation.setSingleSelection(true), {times: 1}); }); +test('#initializeListType populates selectedIndex based on preselected checkbox items', () => { + const {root, component, mockFoundation} = setupTest(); + const listElements = root.querySelectorAll(`.${cssClasses.LIST_ITEM_CLASS}`); + [].map.call(listElements, (itemEl) => itemEl.setAttribute('role', 'checkbox')); + + listElements[2].setAttribute('aria-checked', 'true'); + component.initializeListType(); + td.verify(mockFoundation.setSelectedIndex([2]), {times: 1}); +}); + +test('#initializeListType populates selectedIndex based on preselected radio item', () => { + const {root, component, mockFoundation} = setupTest(); + const listElements = root.querySelectorAll(`.${cssClasses.LIST_ITEM_CLASS}`); + listElements[3].setAttribute('role', 'radio'); + listElements[3].setAttribute('aria-checked', 'true'); + + component.initializeListType(); + td.verify(mockFoundation.setSelectedIndex(3), {times: 1}); +}); + +test('#initializeListType does not populate selectedIndex when no item is selected', () => { + const {component, mockFoundation} = setupTest(); + component.initializeListType(); + td.verify(mockFoundation.setSelectedIndex(td.matchers.anything()), {times: 0}); +}); + test('adapter#getListItemCount returns correct number of list items', () => { const {root, component} = setupTest(); document.body.appendChild(root); @@ -291,6 +318,13 @@ test('selectedIndex calls setSelectedIndex on foundation', () => { td.verify(mockFoundation.setSelectedIndex(1), {times: 1}); }); +test('#selectedIndex getter proxies foundations getSelectedIndex method', () => { + const {component, mockFoundation} = setupTest(); + + td.when(mockFoundation.getSelectedIndex()).thenReturn(3); + assert.equal(3, component.selectedIndex); +}); + test('handleClick handler is added to root element', () => { const {root, mockFoundation} = setupTest(); document.body.appendChild(root);