From 9313e66bf9bc10e8bf83b9bc677a9182a8c4df3a Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Fri, 8 Feb 2019 20:21:37 -0800 Subject: [PATCH 01/16] feat(select): Convert JS to TypeScript Refs #4225 --- packages/mdc-base/component.ts | 9 +- packages/mdc-list/index.ts | 15 +- packages/mdc-menu/index.ts | 6 +- packages/mdc-menu/types.ts | 8 + packages/mdc-select/README.md | 18 +- .../mdc-select/{adapter.js => adapter.ts} | 89 +-- .../mdc-select/{constants.js => constants.ts} | 25 +- .../{foundation.js => foundation.ts} | 123 +-- .../helper-text/{adapter.js => adapter.ts} | 39 +- .../{constants.js => constants.ts} | 2 - .../{foundation.js => foundation.ts} | 70 +- .../helper-text/{index.js => index.ts} | 40 +- packages/mdc-select/icon/README.md | 4 +- .../icon/{adapter.js => adapter.ts} | 43 +- .../icon/{constants.js => constants.ts} | 1 - .../icon/{foundation.js => foundation.ts} | 74 +- .../mdc-select/icon/{index.js => index.ts} | 42 +- packages/mdc-select/index.js | 706 ------------------ packages/mdc-select/index.ts | 597 +++++++++++++++ packages/mdc-select/types.ts | 50 ++ 20 files changed, 893 insertions(+), 1068 deletions(-) rename packages/mdc-select/{adapter.js => adapter.ts} (61%) rename packages/mdc-select/{constants.js => constants.ts} (97%) rename packages/mdc-select/{foundation.js => foundation.ts} (68%) rename packages/mdc-select/helper-text/{adapter.js => adapter.ts} (67%) rename packages/mdc-select/helper-text/{constants.js => constants.ts} (96%) rename packages/mdc-select/helper-text/{foundation.js => foundation.ts} (67%) rename packages/mdc-select/helper-text/{index.js => index.ts} (67%) rename packages/mdc-select/icon/{adapter.js => adapter.ts} (65%) rename packages/mdc-select/icon/{constants.js => constants.ts} (98%) rename packages/mdc-select/icon/{foundation.js => foundation.ts} (58%) rename packages/mdc-select/icon/{index.js => index.ts} (68%) delete mode 100644 packages/mdc-select/index.js create mode 100644 packages/mdc-select/index.ts create mode 100644 packages/mdc-select/types.ts diff --git a/packages/mdc-base/component.ts b/packages/mdc-base/component.ts index 5fadb3cc080..ff83745547a 100644 --- a/packages/mdc-base/component.ts +++ b/packages/mdc-base/component.ts @@ -102,16 +102,13 @@ class MDCComponent { * Fires a cross-browser-compatible custom event from the component root of the given type, * with the given data. */ - emit(evtType: string, evtData: T, shouldBubble = false) { + emit(evtType: string, detail: T, bubbles = false) { let evt: CustomEvent; if (typeof CustomEvent === 'function') { - evt = new CustomEvent(evtType, { - bubbles: shouldBubble, - detail: evtData, - }); + evt = new CustomEvent(evtType, {bubbles, detail}); } else { evt = document.createEvent('CustomEvent'); - evt.initCustomEvent(evtType, shouldBubble, false, evtData); + evt.initCustomEvent(evtType, bubbles, false, detail); } this.root_.dispatchEvent(evt); diff --git a/packages/mdc-list/index.ts b/packages/mdc-list/index.ts index e5035b99143..b57bde92dc3 100644 --- a/packages/mdc-list/index.ts +++ b/packages/mdc-list/index.ts @@ -33,8 +33,8 @@ class MDCList extends MDCComponent { this.foundation_.setVerticalOrientation(value); } - get listElements(): Element[] { - return [].slice.call(this.root_.querySelectorAll(strings.ENABLED_ITEMS_SELECTOR)); + get listElements(): HTMLElement[] { + return [].slice.call(this.root_.querySelectorAll(strings.ENABLED_ITEMS_SELECTOR)); } set wrapFocus(value: boolean) { @@ -104,16 +104,16 @@ class MDCList extends MDCComponent { */ initializeListType() { const checkboxListItems = this.root_.querySelectorAll(strings.ARIA_ROLE_CHECKBOX_SELECTOR); - const singleSelectedListItem = this.root_.querySelector(` + 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); + const radioSelectedListItem = this.root_.querySelector(strings.ARIA_CHECKED_RADIO_SELECTOR); if (checkboxListItems.length) { const preselectedItems = this.root_.querySelectorAll(strings.ARIA_CHECKED_CHECKBOX_SELECTOR); this.selectedIndex = - [].map.call(preselectedItems, (listItem: Element) => this.listElements.indexOf(listItem)) as number[]; + [].map.call(preselectedItems, (listItem: HTMLElement) => this.listElements.indexOf(listItem)) as number[]; } else if (singleSelectedListItem) { if (singleSelectedListItem.classList.contains(cssClasses.LIST_ITEM_ACTIVATED_CLASS)) { this.foundation_.setUseActivatedClass(true); @@ -140,7 +140,7 @@ class MDCList extends MDCComponent { element.focus(); } }, - getFocusedElementIndex: () => this.listElements.indexOf(document.activeElement!), + getFocusedElementIndex: () => this.listElements.indexOf(document.activeElement as HTMLElement), getListItemCount: () => this.listElements.length, hasCheckboxAtIndex: (index) => { const listItem = this.listElements[index]; @@ -203,7 +203,8 @@ class MDCList extends MDCComponent { */ private getListItemIndex_(evt: Event) { const eventTarget = evt.target as Element; - const nearestParent = ponyfill.closest(eventTarget, `.${cssClasses.LIST_ITEM_CLASS}, .${cssClasses.ROOT}`); + const nearestParent = + ponyfill.closest(eventTarget, `.${cssClasses.LIST_ITEM_CLASS}, .${cssClasses.ROOT}`); // Get the index of the element if it is a list item. if (nearestParent && ponyfill.matches(nearestParent, `.${cssClasses.LIST_ITEM_CLASS}`)) { diff --git a/packages/mdc-menu/index.ts b/packages/mdc-menu/index.ts index c371037b248..8d8881a28a1 100644 --- a/packages/mdc-menu/index.ts +++ b/packages/mdc-menu/index.ts @@ -108,7 +108,7 @@ class MDCMenu extends MDCComponent { * the items container that are proper list items, and not supplemental / presentational DOM * elements. */ - get items(): Element[] { + get items(): HTMLElement[] { return this.list_ ? this.list_.listElements : []; } @@ -191,10 +191,10 @@ class MDCMenu extends MDCComponent { }, elementContainsClass: (element, className) => element.classList.contains(className), closeSurface: () => this.open = false, - getElementIndex: (element) => this.items.indexOf(element), + getElementIndex: (element) => this.items.indexOf(element as HTMLElement), getParentElement: (element) => element.parentElement, getSelectedElementIndex: (selectionGroup) => { - const selectedListItem = selectionGroup.querySelector(`.${cssClasses.MENU_SELECTED_LIST_ITEM}`); + const selectedListItem = selectionGroup.querySelector(`.${cssClasses.MENU_SELECTED_LIST_ITEM}`); return selectedListItem ? this.items.indexOf(selectedListItem) : -1; }, notifySelected: (evtData) => this.emit(strings.SELECTED_EVENT, { diff --git a/packages/mdc-menu/types.ts b/packages/mdc-menu/types.ts index 5a96a05b009..403d460f4bb 100644 --- a/packages/mdc-menu/types.ts +++ b/packages/mdc-menu/types.ts @@ -24,6 +24,14 @@ import {MDCList} from '@material/list/index'; import {MDCMenuSurface} from '@material/menu-surface/index'; +export interface MenuItemEvent extends Event { + detail: MenuItemEventDetail; +} + +export interface DefaultMenuItemEvent extends Event { + detail: DefaultMenuItemEventDetail; +} + /** * Event properties used by the adapter and foundation. */ diff --git a/packages/mdc-select/README.md b/packages/mdc-select/README.md index f30eb1254a7..28c34c55493 100644 --- a/packages/mdc-select/README.md +++ b/packages/mdc-select/README.md @@ -407,14 +407,14 @@ The `MDCSelect` component API is modeled after a subset of the `HTMLSelectElemen | Property | Type | Description | | --- | --- | --- | -| `value` | string | The `value`/`data-value` of the currently selected option. | -| `selectedIndex` | number | The index of the currently selected option. Set to -1 if no option is currently selected. Changing this property will update the select element. | -| `disabled` | boolean | Whether or not the component is disabled. Setting this sets the disabled state on the component. | -| `valid` | boolean | Whether or not the component is in a valid state. Setting this updates styles on the component, but does not affect the native validity state. | -| `required` | boolean | Whether or not the component is required. Setting this updates the `required` or `aria-required` attribute on the component and enables validation. | -| `leadingIconAriaLabel` | string (write-only) | Proxies to the foundation's `setLeadingIconAriaLabel` method. | -| `leadingIconContent` | string (write-only) | Proxies to the foundation's `setLeadingIconContent` method. | -| `helperTextContent` | string (write-only)| Proxies to the foundation's `setHelperTextContent` method when set. | +| `value` | `string` | The `value`/`data-value` of the currently selected option. | +| `selectedIndex` | `number` | The index of the currently selected option. Set to -1 if no option is currently selected. Changing this property will update the select element. | +| `disabled` | `boolean` | Whether or not the component is disabled. Setting this sets the disabled state on the component. | +| `valid` | `boolean` | Whether or not the component is in a valid state. Setting this updates styles on the component, but does not affect the native validity state. | +| `required` | `boolean` | Whether or not the component is required. Setting this updates the `required` or `aria-required` attribute on the component and enables validation. | +| `leadingIconAriaLabel` | `string` (write-only) | Proxies to the foundation's `setLeadingIconAriaLabel` method. | +| `leadingIconContent` | `string` (write-only) | Proxies to the foundation's `setLeadingIconContent` method. | +| `helperTextContent` | `string` (write-only)| Proxies to the foundation's `setHelperTextContent` method when set. | ### Events @@ -462,7 +462,7 @@ If you are using a JavaScript framework, such as React or Angular, you can creat | `handleBlur() => void` | Handles a blur event on the `select` element. | | `handleClick(normalizedX: number) => void` | Sets the line ripple center to the normalizedX for the line ripple. | | `handleChange() => void` | Handles a change to the `select` element's value. This must be called both for `change` events and programmatic changes requested via the component API. | -| `handleKeydown(event: Event) => void` | Handles opening the menu (enhanced select) when the `mdc-select__selected-text` element is focused and the user presses the `Enter` or `Space` key. | +| `handleKeydown(event: KeyboardEvent) => void` | Handles opening the menu (enhanced select) when the `mdc-select__selected-text` element is focused and the user presses the `Enter` or `Space` key. | | `setSelectedIndex(index: number) => void` | Handles setting the `mdc-select__selected-text` element and closing the menu (enhanced select only). Also causes the label to float and outline to notch if needed. | | `setValue(value: string) => void` | Handles setting the value through the adapter and causes the label to float and outline to notch if needed. | | `getValue() => string` | Handles getting the value through the adapter. | diff --git a/packages/mdc-select/adapter.js b/packages/mdc-select/adapter.ts similarity index 61% rename from packages/mdc-select/adapter.js rename to packages/mdc-select/adapter.ts index be1ab512076..a217c11fc1a 100644 --- a/packages/mdc-select/adapter.js +++ b/packages/mdc-select/adapter.ts @@ -21,159 +21,118 @@ * THE SOFTWARE. */ -/* eslint no-unused-vars: [2, {"args": "none"}] */ -/* eslint-disable no-unused-vars */ -import {MDCSelectIconFoundation} from './icon/index'; -import {MDCSelectHelperTextFoundation} from './helper-text/index'; -/* eslint-enable no-unused-vars */ - -/** - * @typedef {{ - * leadingIcon: (!MDCSelectIconFoundation|undefined), - * helperText: (!MDCSelectHelperTextFoundation|undefined), - * }} - */ -let FoundationMapType; - /** - * Adapter for MDC Select. Provides an interface for managing - * - classes - * - dom - * - event handlers - * - * Additionally, provides type information for the adapter to the Closure - * compiler. - * + * Defines the shape of the adapter expected by the foundation. * Implement this adapter for your framework of choice to delegate updates to * the component in your framework of choice. See architecture documentation * for more details. * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md - * - * @record */ - -class MDCSelectAdapter { +interface MDCSelectAdapter { /** * Adds class to root element. - * @param {string} className */ - addClass(className) {} + addClass(className: string): void; /** * Removes a class from the root element. - * @param {string} className */ - removeClass(className) {} + removeClass(className: string): void; /** * Returns true if the root element contains the given class name. - * @param {string} className - * @return {boolean} */ - hasClass(className) {} + hasClass(className: string): boolean; /** * Activates the bottom line, showing a focused state. */ - activateBottomLine() {} + activateBottomLine(): void; /** * Deactivates the bottom line. */ - deactivateBottomLine() {} + deactivateBottomLine(): void; /** * Sets the value of the select. - * @param {string} value */ - setValue(value) {} + setValue(value: string): void; /** * Returns the selected value of the select element. - * @return {string} */ - getValue() {} + getValue(): string; /** * Floats label determined based off of the shouldFloat argument. - * @param {boolean} shouldFloat */ - floatLabel(shouldFloat) {} + floatLabel(shouldFloat: boolean): void; /** * Returns width of label in pixels, if the label exists. - * @return {number} */ - getLabelWidth() {} + getLabelWidth(): number; /** * Returns true if outline element exists, false if it doesn't. - * @return {boolean} */ - hasOutline() {} + hasOutline(): boolean; /** * Only implement if outline element exists. - * @param {number} labelWidth */ - notchOutline(labelWidth) {} + notchOutline(labelWidth: number): void; /** * Closes notch in outline element, if the outline exists. */ - closeOutline() {} + closeOutline(): void; /** * Opens the menu. */ - openMenu() {} + openMenu(): void; /** * Closes the menu. */ - closeMenu() {} + closeMenu(): void; /** * Returns true if the menu is currently open. - * @return {boolean} */ - isMenuOpen() {} + isMenuOpen(): boolean; /** * Sets the selected index of the select to the index provided. - * @param {number} index */ - setSelectedIndex(index) {} + setSelectedIndex(index: number): void; /** * Sets the select to disabled. - * @param {boolean} isDisabled */ - setDisabled(isDisabled) {} + setDisabled(isDisabled: boolean): void; /** * Sets the line ripple transform origin center. - * @param {number} normalizedX */ - setRippleCenter(normalizedX) {} + setRippleCenter(normalizedX: number): void; /** * Emits a change event when an element is selected. - * @param {string} value */ - notifyChange(value) {} + notifyChange(value: string): void; /** * Checks if the select is currently valid. - * @return {boolean} isValid */ - checkValidity() {} + checkValidity(): boolean; /** * Adds/Removes the invalid class. - * @param {boolean} isValid */ - setValid(isValid) {} + setValid(isValid: boolean): void; } -export {MDCSelectAdapter, FoundationMapType}; +export {MDCSelectAdapter as default, MDCSelectAdapter}; diff --git a/packages/mdc-select/constants.js b/packages/mdc-select/constants.ts similarity index 97% rename from packages/mdc-select/constants.js rename to packages/mdc-select/constants.ts index bb9d343630f..585a88dbbea 100644 --- a/packages/mdc-select/constants.js +++ b/packages/mdc-select/constants.ts @@ -21,36 +21,33 @@ * THE SOFTWARE. */ -/** @enum {string} */ const cssClasses = { DISABLED: 'mdc-select--disabled', - ROOT: 'mdc-select', - OUTLINED: 'mdc-select--outlined', FOCUSED: 'mdc-select--focused', - SELECTED_ITEM_CLASS: 'mdc-list-item--selected', - WITH_LEADING_ICON: 'mdc-select--with-leading-icon', INVALID: 'mdc-select--invalid', + OUTLINED: 'mdc-select--outlined', REQUIRED: 'mdc-select--required', + ROOT: 'mdc-select', + SELECTED_ITEM_CLASS: 'mdc-list-item--selected', + WITH_LEADING_ICON: 'mdc-select--with-leading-icon', }; -/** @enum {string} */ const strings = { ARIA_CONTROLS: 'aria-controls', + ARIA_SELECTED_ATTR: 'aria-selected', CHANGE_EVENT: 'MDCSelect:change', - SELECTED_ITEM_SELECTOR: `.${cssClasses.SELECTED_ITEM_CLASS}`, - LEADING_ICON_SELECTOR: '.mdc-select__icon', - SELECTED_TEXT_SELECTOR: '.mdc-select__selected-text', + ENHANCED_VALUE_ATTR: 'data-value', HIDDEN_INPUT_SELECTOR: 'input[type="hidden"]', - MENU_SELECTOR: '.mdc-select__menu', - LINE_RIPPLE_SELECTOR: '.mdc-line-ripple', LABEL_SELECTOR: '.mdc-floating-label', + LEADING_ICON_SELECTOR: '.mdc-select__icon', + LINE_RIPPLE_SELECTOR: '.mdc-line-ripple', + MENU_SELECTOR: '.mdc-select__menu', NATIVE_CONTROL_SELECTOR: '.mdc-select__native-control', OUTLINE_SELECTOR: '.mdc-notched-outline', - ENHANCED_VALUE_ATTR: 'data-value', - ARIA_SELECTED_ATTR: 'aria-selected', + SELECTED_ITEM_SELECTOR: `.${cssClasses.SELECTED_ITEM_CLASS}`, + SELECTED_TEXT_SELECTOR: '.mdc-select__selected-text', }; -/** @enum {number} */ const numbers = { LABEL_SCALE: 0.75, }; diff --git a/packages/mdc-select/foundation.js b/packages/mdc-select/foundation.ts similarity index 68% rename from packages/mdc-select/foundation.js rename to packages/mdc-select/foundation.ts index d295783a7b0..b3b5aa93051 100644 --- a/packages/mdc-select/foundation.js +++ b/packages/mdc-select/foundation.ts @@ -22,85 +22,79 @@ */ import {MDCFoundation} from '@material/base/foundation'; -/* eslint-disable no-unused-vars */ -import {MDCSelectAdapter, FoundationMapType} from './adapter'; -import {MDCSelectIconFoundation} from './icon/index'; + +import {MDCSelectAdapter} from './adapter'; +import {cssClasses, numbers, strings} from './constants'; +import {FoundationMapType} from './types'; import {MDCSelectHelperTextFoundation} from './helper-text/index'; -/* eslint-enable no-unused-vars */ -import {cssClasses, strings, numbers} from './constants'; +import {MDCSelectIconFoundation} from './icon/index'; -/** - * @extends {MDCFoundation} - * @final - */ -class MDCSelectFoundation extends MDCFoundation { - /** @return enum {string} */ +class MDCSelectFoundation extends MDCFoundation { static get cssClasses() { return cssClasses; } - /** @return enum {number} */ static get numbers() { return numbers; } - /** @return enum {string} */ static get strings() { return strings; } /** - * {@see MDCSelectAdapter} for typing information on parameters and return - * types. - * @return {!MDCSelectAdapter} + * See {@link MDCSelectAdapter} for typing information on parameters and return types. */ - static get defaultAdapter() { - return /** @type {!MDCSelectAdapter} */ ({ - addClass: (/* className: string */) => {}, - removeClass: (/* className: string */) => {}, + static get defaultAdapter(): MDCSelectAdapter { + // tslint:disable:object-literal-sort-keys + return { + addClass: (/* className: string */) => undefined, + removeClass: (/* className: string */) => undefined, hasClass: (/* className: string */) => false, - activateBottomLine: () => {}, - deactivateBottomLine: () => {}, - setValue: () => {}, - getValue: () => {}, - floatLabel: (/* value: boolean */) => {}, - getLabelWidth: () => {}, + activateBottomLine: () => undefined, + deactivateBottomLine: () => undefined, + setValue: () => undefined, + getValue: () => '', + floatLabel: (/* value: boolean */) => undefined, + getLabelWidth: () => 0, hasOutline: () => false, - notchOutline: (/* labelWidth: number, */) => {}, - closeOutline: () => {}, - openMenu: () => {}, - closeMenu: () => {}, - isMenuOpen: () => {}, - setSelectedIndex: () => {}, - setDisabled: () => {}, - setRippleCenter: () => {}, - notifyChange: () => {}, - checkValidity: () => {}, - setValid: () => {}, - }); + notchOutline: (/* labelWidth: number, */) => undefined, + closeOutline: () => undefined, + openMenu: () => undefined, + closeMenu: () => undefined, + isMenuOpen: () => false, + setSelectedIndex: () => undefined, + setDisabled: () => undefined, + setRippleCenter: () => undefined, + notifyChange: () => undefined, + checkValidity: () => false, + setValid: () => undefined, + }; + // tslint:enable:object-literal-sort-keys } + private readonly leadingIcon_: MDCSelectIconFoundation | undefined; + private readonly helperText_: MDCSelectHelperTextFoundation | undefined; + /** * @param {!MDCSelectAdapter} adapter * @param {!FoundationMapType=} foundationMap Map from subcomponent names to their subfoundations. */ - constructor(adapter, foundationMap = /** @type {!FoundationMapType} */ ({})) { - super(Object.assign(MDCSelectFoundation.defaultAdapter, adapter)); + constructor(adapter: Partial = {}, foundationMap: Partial = {}) { + super({...MDCSelectFoundation.defaultAdapter, ...adapter}); - /** @type {!MDCSelectIconFoundation|undefined} */ this.leadingIcon_ = foundationMap.leadingIcon; - /** @type {!MDCSelectHelperTextFoundation|undefined} */ this.helperText_ = foundationMap.helperText; } - setSelectedIndex(index) { + setSelectedIndex(index: number) { this.adapter_.setSelectedIndex(index); this.adapter_.closeMenu(); const didChange = true; this.handleChange(didChange); } - setValue(value) { + setValue(value: string) { this.adapter_.setValue(value); const didChange = true; this.handleChange(didChange); @@ -110,8 +104,12 @@ class MDCSelectFoundation extends MDCFoundation { return this.adapter_.getValue(); } - setDisabled(isDisabled) { - isDisabled ? this.adapter_.addClass(cssClasses.DISABLED) : this.adapter_.removeClass(cssClasses.DISABLED); + setDisabled(isDisabled: boolean) { + if (isDisabled) { + this.adapter_.addClass(cssClasses.DISABLED); + } else { + this.adapter_.removeClass(cssClasses.DISABLED); + } this.adapter_.setDisabled(isDisabled); this.adapter_.closeMenu(); @@ -121,9 +119,9 @@ class MDCSelectFoundation extends MDCFoundation { } /** - * @param {string} content Sets the content of the helper text. + * @param content Sets the content of the helper text. */ - setHelperTextContent(content) { + setHelperTextContent(content: string) { if (this.helperText_) { this.helperText_.setContent(content); } @@ -177,7 +175,9 @@ class MDCSelectFoundation extends MDCFoundation { * Handles blur events from select element. */ handleBlur() { - if (this.adapter_.isMenuOpen()) return; + if (this.adapter_.isMenuOpen()) { + return; + } this.adapter_.removeClass(cssClasses.FOCUSED); this.handleChange(false); this.adapter_.deactivateBottomLine(); @@ -192,15 +192,19 @@ class MDCSelectFoundation extends MDCFoundation { } } - handleClick(normalizedX) { - if (this.adapter_.isMenuOpen()) return; + handleClick(normalizedX: number) { + if (this.adapter_.isMenuOpen()) { + return; + } this.adapter_.setRippleCenter(normalizedX); this.adapter_.openMenu(); } - handleKeydown(event) { - if (this.adapter_.isMenuOpen()) return; + handleKeydown(event: KeyboardEvent) { + if (this.adapter_.isMenuOpen()) { + return; + } const isEnter = event.key === 'Enter' || event.keyCode === 13; const isSpace = event.key === 'Space' || event.keyCode === 32; @@ -215,9 +219,8 @@ class MDCSelectFoundation extends MDCFoundation { /** * Opens/closes the notched outline. - * @param {boolean} openNotch */ - notchOutline(openNotch) { + notchOutline(openNotch: boolean) { if (!this.adapter_.hasOutline()) { return; } @@ -234,9 +237,8 @@ class MDCSelectFoundation extends MDCFoundation { /** * Sets the aria label of the leading icon. - * @param {string} label */ - setLeadingIconAriaLabel(label) { + setLeadingIconAriaLabel(label: string) { if (this.leadingIcon_) { this.leadingIcon_.setAriaLabel(label); } @@ -244,15 +246,14 @@ class MDCSelectFoundation extends MDCFoundation { /** * Sets the text content of the leading icon. - * @param {string} content */ - setLeadingIconContent(content) { + setLeadingIconContent(content: string) { if (this.leadingIcon_) { this.leadingIcon_.setContent(content); } } - setValid(isValid) { + setValid(isValid: boolean) { this.adapter_.setValid(isValid); } @@ -261,4 +262,4 @@ class MDCSelectFoundation extends MDCFoundation { } } -export default MDCSelectFoundation; +export {MDCSelectFoundation as default, MDCSelectFoundation}; diff --git a/packages/mdc-select/helper-text/adapter.js b/packages/mdc-select/helper-text/adapter.ts similarity index 67% rename from packages/mdc-select/helper-text/adapter.js rename to packages/mdc-select/helper-text/adapter.ts index ce0f8c75780..e62a94cece1 100644 --- a/packages/mdc-select/helper-text/adapter.js +++ b/packages/mdc-select/helper-text/adapter.ts @@ -21,56 +21,43 @@ * THE SOFTWARE. */ -/* eslint no-unused-vars: [2, {"args": "none"}] */ - /** - * Adapter for MDC Select Helper Text. - * - * Defines the shape of the adapter expected by the foundation. Implement this - * adapter to integrate the Select helper text into your framework. See - * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md - * for more information. - * - * @record + * Defines the shape of the adapter expected by the foundation. + * Implement this adapter for your framework of choice to delegate updates to + * the component in your framework of choice. See architecture documentation + * for more details. + * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md */ -class MDCSelectHelperTextAdapter { +interface MDCSelectHelperTextAdapter { /** * Adds a class to the helper text element. - * @param {string} className */ - addClass(className) {} + addClass(className: string): void; /** * Removes a class from the helper text element. - * @param {string} className */ - removeClass(className) {} + removeClass(className: string): void; /** * Returns whether or not the helper text element contains the given class. - * @param {string} className - * @return {boolean} */ - hasClass(className) {} + hasClass(className: string): boolean; /** * Sets an attribute with a given value on the helper text element. - * @param {string} attr - * @param {string} value */ - setAttr(attr, value) {} + setAttr(attr: string, value: string): void; /** * Removes an attribute from the helper text element. - * @param {string} attr */ - removeAttr(attr) {} + removeAttr(attr: string): void; /** * Sets the text content for the helper text element. - * @param {string} content */ - setContent(content) {} + setContent(content: string): void; } -export default MDCSelectHelperTextAdapter; +export {MDCSelectHelperTextAdapter as default, MDCSelectHelperTextAdapter}; diff --git a/packages/mdc-select/helper-text/constants.js b/packages/mdc-select/helper-text/constants.ts similarity index 96% rename from packages/mdc-select/helper-text/constants.js rename to packages/mdc-select/helper-text/constants.ts index d35cce736ea..6cac34f4189 100644 --- a/packages/mdc-select/helper-text/constants.js +++ b/packages/mdc-select/helper-text/constants.ts @@ -21,13 +21,11 @@ * THE SOFTWARE. */ -/** @enum {string} */ const strings = { ARIA_HIDDEN: 'aria-hidden', ROLE: 'role', }; -/** @enum {string} */ const cssClasses = { HELPER_TEXT_PERSISTENT: 'mdc-select-helper-text--persistent', HELPER_TEXT_VALIDATION_MSG: 'mdc-select-helper-text--validation-msg', diff --git a/packages/mdc-select/helper-text/foundation.js b/packages/mdc-select/helper-text/foundation.ts similarity index 67% rename from packages/mdc-select/helper-text/foundation.js rename to packages/mdc-select/helper-text/foundation.ts index 9a3622880e9..107ad7db1fa 100644 --- a/packages/mdc-select/helper-text/foundation.js +++ b/packages/mdc-select/helper-text/foundation.ts @@ -22,58 +22,49 @@ */ import {MDCFoundation} from '@material/base/foundation'; -import MDCSelectHelperTextAdapter from './adapter'; +import {MDCSelectHelperTextAdapter} from './adapter'; import {cssClasses, strings} from './constants'; - -/** - * @extends {MDCFoundation} - * @final - */ -class MDCSelectHelperTextFoundation extends MDCFoundation { - /** @return enum {string} */ +class MDCSelectHelperTextFoundation extends MDCFoundation { static get cssClasses() { return cssClasses; } - /** @return enum {string} */ static get strings() { return strings; } /** - * {@see MDCSelectHelperTextAdapter} for typing information on parameters and return - * types. - * @return {!MDCSelectHelperTextAdapter} + * See {@link MDCSelectHelperTextAdapter} for typing information on parameters and return types. */ - static get defaultAdapter() { - return /** @type {!MDCSelectHelperTextAdapter} */ ({ - addClass: () => {}, - removeClass: () => {}, - hasClass: () => {}, - setAttr: () => {}, - removeAttr: () => {}, - setContent: () => {}, - }); + static get defaultAdapter(): MDCSelectHelperTextAdapter { + // tslint:disable:object-literal-sort-keys + return { + addClass: () => undefined, + removeClass: () => undefined, + hasClass: () => false, + setAttr: () => undefined, + removeAttr: () => undefined, + setContent: () => undefined, + }; + // tslint:enable:object-literal-sort-keys } - /** - * @param {!MDCSelectHelperTextAdapter} adapter - */ - constructor(adapter) { - super(Object.assign(MDCSelectHelperTextFoundation.defaultAdapter, adapter)); - } + constructor(adapter: Partial = {}) { + super({...MDCSelectHelperTextFoundation.defaultAdapter, ...adapter}); +} /** * Sets the content of the helper text field. - * @param {string} content */ - setContent(content) { + setContent(content: string) { this.adapter_.setContent(content); } - /** @param {boolean} isPersistent Sets the persistency of the helper text. */ - setPersistent(isPersistent) { + /** + * Sets the persistency of the helper text. + */ + setPersistent(isPersistent: boolean) { if (isPersistent) { this.adapter_.addClass(cssClasses.HELPER_TEXT_PERSISTENT); } else { @@ -82,10 +73,9 @@ class MDCSelectHelperTextFoundation extends MDCFoundation { } /** - * @param {boolean} isValidation True to make the helper text act as an - * error validation message. + * @param isValidation True to make the helper text act as an error validation message. */ - setValidation(isValidation) { + setValidation(isValidation: boolean) { if (isValidation) { this.adapter_.addClass(cssClasses.HELPER_TEXT_VALIDATION_MSG); } else { @@ -93,16 +83,17 @@ class MDCSelectHelperTextFoundation extends MDCFoundation { } } - /** Makes the helper text visible to the screen reader. */ + /** + * Makes the helper text visible to screen readers. + */ showToScreenReader() { this.adapter_.removeAttr(strings.ARIA_HIDDEN); } /** * Sets the validity of the helper text based on the select validity. - * @param {boolean} selectIsValid */ - setValidity(selectIsValid) { + setValidity(selectIsValid: boolean) { const helperTextIsPersistent = this.adapter_.hasClass(cssClasses.HELPER_TEXT_PERSISTENT); const helperTextIsValidationMsg = this.adapter_.hasClass(cssClasses.HELPER_TEXT_VALIDATION_MSG); const validationMsgNeedsDisplay = helperTextIsValidationMsg && !selectIsValid; @@ -120,11 +111,10 @@ class MDCSelectHelperTextFoundation extends MDCFoundation { /** * Hides the help text from screen readers. - * @private */ - hide_() { + private hide_() { this.adapter_.setAttr(strings.ARIA_HIDDEN, 'true'); } } -export default MDCSelectHelperTextFoundation; +export {MDCSelectHelperTextFoundation as default, MDCSelectHelperTextFoundation}; diff --git a/packages/mdc-select/helper-text/index.js b/packages/mdc-select/helper-text/index.ts similarity index 67% rename from packages/mdc-select/helper-text/index.js rename to packages/mdc-select/helper-text/index.ts index 7cc2e65233f..20e1eaf11cd 100644 --- a/packages/mdc-select/helper-text/index.js +++ b/packages/mdc-select/helper-text/index.ts @@ -22,45 +22,31 @@ */ import {MDCComponent} from '@material/base/component'; +import {MDCSelectHelperTextFoundation} from './foundation'; -import MDCSelectHelperTextAdapter from './adapter'; -import MDCSelectHelperTextFoundation from './foundation'; - -/** - * @extends {MDCComponent} - * @final - */ -class MDCSelectHelperText extends MDCComponent { - /** - * @param {!Element} root - * @return {!MDCSelectHelperText} - */ - static attachTo(root) { +class MDCSelectHelperText extends MDCComponent { + static attachTo(root: Element): MDCSelectHelperText { return new MDCSelectHelperText(root); } - /** - * @return {!MDCSelectHelperTextFoundation} - */ - get foundation() { + get foundation(): MDCSelectHelperTextFoundation { return this.foundation_; } - /** - * @return {!MDCSelectHelperTextFoundation} - */ - getDefaultFoundation() { - return new MDCSelectHelperTextFoundation(/** @type {!MDCSelectHelperTextAdapter} */ (Object.assign({ + getDefaultFoundation(): MDCSelectHelperTextFoundation { + // tslint:disable:object-literal-sort-keys + return new MDCSelectHelperTextFoundation({ addClass: (className) => this.root_.classList.add(className), removeClass: (className) => this.root_.classList.remove(className), hasClass: (className) => this.root_.classList.contains(className), setAttr: (attr, value) => this.root_.setAttribute(attr, value), removeAttr: (attr) => this.root_.removeAttribute(attr), - setContent: (content) => { - this.root_.textContent = content; - }, - }))); + setContent: (content) => this.root_.textContent = content, + }); + // tslint:enable:object-literal-sort-keys } } -export {MDCSelectHelperText, MDCSelectHelperTextFoundation}; +export {MDCSelectHelperText as default, MDCSelectHelperText}; +export * from './adapter'; +export * from './foundation'; diff --git a/packages/mdc-select/icon/README.md b/packages/mdc-select/icon/README.md index 39062c6deb7..aa032d6129c 100644 --- a/packages/mdc-select/icon/README.md +++ b/packages/mdc-select/icon/README.md @@ -114,8 +114,8 @@ Method Signature | Description `setAttr(attr: string, value: string) => void` | Sets an attribute with a given value on the icon element. `removeAttr(attr: string) => void` | Removes an attribute from the icon element. `setContent(content: string) => void` | Sets the text content of the icon element. -`registerInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event listener for a given event. -`deregisterInteractionHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener for a given event. +`registerInteractionHandler(evtType: EventType, handler: EventListener) => void` | Registers an event listener for a given event. +`deregisterInteractionHandler(evtType: EventType, handler: EventListener) => void` | Deregisters an event listener for a given event. `notifyIconAction() => void` | Emits a custom event "MDCSelect:icon" denoting a user has clicked the icon, which bubbles to the top-level select element. ### `MDCSelectIconFoundation` diff --git a/packages/mdc-select/icon/adapter.js b/packages/mdc-select/icon/adapter.ts similarity index 65% rename from packages/mdc-select/icon/adapter.js rename to packages/mdc-select/icon/adapter.ts index 7da053db045..f9cf4d5325f 100644 --- a/packages/mdc-select/icon/adapter.js +++ b/packages/mdc-select/icon/adapter.ts @@ -21,63 +21,50 @@ * THE SOFTWARE. */ -/* eslint no-unused-vars: [2, {"args": "none"}] */ +import {EventType, SpecificEventListener} from '@material/base/index'; /** - * Adapter for MDC Select Icon. - * - * Defines the shape of the adapter expected by the foundation. Implement this - * adapter to integrate the select icon into your framework. See - * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md - * for more information. - * - * @record + * Defines the shape of the adapter expected by the foundation. + * Implement this adapter for your framework of choice to delegate updates to + * the component in your framework of choice. See architecture documentation + * for more details. + * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md */ -class MDCSelectIconAdapter { +interface MDCSelectIconAdapter { /** * Gets the value of an attribute on the icon element. - * @param {string} attr - * @return {string} */ - getAttr(attr) {} + getAttr(attr: string): string | null; /** * Sets an attribute on the icon element. - * @param {string} attr - * @param {string} value */ - setAttr(attr, value) {} + setAttr(attr: string, value: string): void; /** * Removes an attribute from the icon element. - * @param {string} attr */ - removeAttr(attr) {} + removeAttr(attr: string): void; /** * Sets the text content of the icon element. - * @param {string} content */ - setContent(content) {} + setContent(content: string): void; /** * Registers an event listener on the icon element for a given event. - * @param {string} evtType - * @param {function(!Event): undefined} handler */ - registerInteractionHandler(evtType, handler) {} + registerInteractionHandler(evtType: E, handler: SpecificEventListener): void; /** * Deregisters an event listener on the icon element for a given event. - * @param {string} evtType - * @param {function(!Event): undefined} handler */ - deregisterInteractionHandler(evtType, handler) {} + deregisterInteractionHandler(evtType: E, handler: SpecificEventListener): void; /** * Emits a custom event "MDCSelect:icon" denoting a user has clicked the icon. */ - notifyIconAction() {} + notifyIconAction(): void; } -export default MDCSelectIconAdapter; +export {MDCSelectIconAdapter as default, MDCSelectIconAdapter}; diff --git a/packages/mdc-select/icon/constants.js b/packages/mdc-select/icon/constants.ts similarity index 98% rename from packages/mdc-select/icon/constants.js rename to packages/mdc-select/icon/constants.ts index 5a61d074ad7..50a45780fa4 100644 --- a/packages/mdc-select/icon/constants.js +++ b/packages/mdc-select/icon/constants.ts @@ -21,7 +21,6 @@ * THE SOFTWARE. */ -/** @enum {string} */ const strings = { ICON_EVENT: 'MDCSelect:icon', ICON_ROLE: 'button', diff --git a/packages/mdc-select/icon/foundation.js b/packages/mdc-select/icon/foundation.ts similarity index 58% rename from packages/mdc-select/icon/foundation.js rename to packages/mdc-select/icon/foundation.ts index 3524488224c..39f0b937491 100644 --- a/packages/mdc-select/icon/foundation.js +++ b/packages/mdc-select/icon/foundation.ts @@ -22,66 +22,59 @@ */ import {MDCFoundation} from '@material/base/foundation'; -import MDCSelectIconAdapter from './adapter'; +import {EventType} from '@material/base/index'; +import {MDCSelectIconAdapter} from './adapter'; import {strings} from './constants'; +const CLICK_KEYDOWN_EVENTS: EventType[] = ['click', 'keydown']; -/** - * @extends {MDCFoundation} - * @final - */ -class MDCSelectIconFoundation extends MDCFoundation { - /** @return enum {string} */ +class MDCSelectIconFoundation extends MDCFoundation { static get strings() { return strings; } /** - * {@see MDCSelectIconAdapter} for typing information on parameters and return - * types. - * @return {!MDCSelectIconAdapter} + * See {@link MDCSelectIconAdapter} for typing information on parameters and return types. */ - static get defaultAdapter() { - return /** @type {!MDCSelectIconAdapter} */ ({ - getAttr: () => {}, - setAttr: () => {}, - removeAttr: () => {}, - setContent: () => {}, - registerInteractionHandler: () => {}, - deregisterInteractionHandler: () => {}, - notifyIconAction: () => {}, - }); + static get defaultAdapter(): MDCSelectIconAdapter { + // tslint:disable:object-literal-sort-keys + return { + getAttr: () => null, + setAttr: () => undefined, + removeAttr: () => undefined, + setContent: () => undefined, + registerInteractionHandler: () => undefined, + deregisterInteractionHandler: () => undefined, + notifyIconAction: () => undefined, + }; + // tslint:enable:object-literal-sort-keys } - /** - * @param {!MDCSelectIconAdapter} adapter - */ - constructor(adapter) { - super(Object.assign(MDCSelectIconFoundation.defaultAdapter, adapter)); + private savedTabIndex_!: string | null; // assigned in initialSyncWithDOM() + private readonly interactionHandler_!: EventListener; // assigned in initialSyncWithDOM() - /** @private {string?} */ - this.savedTabIndex_ = null; + constructor(adapter: Partial = {}) { + super({...MDCSelectIconFoundation.defaultAdapter, ...adapter}); - /** @private {function(!Event): undefined} */ + this.savedTabIndex_ = null; this.interactionHandler_ = (evt) => this.handleInteraction(evt); } init() { this.savedTabIndex_ = this.adapter_.getAttr('tabindex'); - ['click', 'keydown'].forEach((evtType) => { + CLICK_KEYDOWN_EVENTS.forEach((evtType) => { this.adapter_.registerInteractionHandler(evtType, this.interactionHandler_); }); } destroy() { - ['click', 'keydown'].forEach((evtType) => { + CLICK_KEYDOWN_EVENTS.forEach((evtType) => { this.adapter_.deregisterInteractionHandler(evtType, this.interactionHandler_); }); } - /** @param {boolean} disabled */ - setDisabled(disabled) { + setDisabled(disabled: boolean) { if (!this.savedTabIndex_) { return; } @@ -95,25 +88,20 @@ class MDCSelectIconFoundation extends MDCFoundation { } } - /** @param {string} label */ - setAriaLabel(label) { + setAriaLabel(label: string) { this.adapter_.setAttr('aria-label', label); } - /** @param {string} content */ - setContent(content) { + setContent(content: string) { this.adapter_.setContent(content); } - /** - * Handles an interaction event - * @param {!Event} evt - */ - handleInteraction(evt) { - if (evt.type === 'click' || evt.key === 'Enter' || evt.keyCode === 13) { + handleInteraction(evt: Event) { + const isEnterKey = (evt as KeyboardEvent).key === 'Enter' || (evt as KeyboardEvent).keyCode === 13; + if (evt.type === 'click' || isEnterKey) { this.adapter_.notifyIconAction(); } } } -export default MDCSelectIconFoundation; +export {MDCSelectIconFoundation as default, MDCSelectIconFoundation}; diff --git a/packages/mdc-select/icon/index.js b/packages/mdc-select/icon/index.ts similarity index 68% rename from packages/mdc-select/icon/index.js rename to packages/mdc-select/icon/index.ts index bfce7f22818..f4fb5c65e9d 100644 --- a/packages/mdc-select/icon/index.js +++ b/packages/mdc-select/icon/index.ts @@ -22,47 +22,33 @@ */ import {MDCComponent} from '@material/base/component'; +import {MDCSelectIconFoundation} from './foundation'; -import MDCSelectIconAdapter from './adapter'; -import MDCSelectIconFoundation from './foundation'; - -/** - * @extends {MDCComponent} - * @final - */ -class MDCSelectIcon extends MDCComponent { - /** - * @param {!Element} root - * @return {!MDCSelectIcon} - */ - static attachTo(root) { +class MDCSelectIcon extends MDCComponent { + static attachTo(root: Element): MDCSelectIcon { return new MDCSelectIcon(root); } - /** - * @return {!MDCSelectIconFoundation} - */ - get foundation() { + get foundation(): MDCSelectIconFoundation { return this.foundation_; } - /** - * @return {!MDCSelectIconFoundation} - */ - getDefaultFoundation() { - return new MDCSelectIconFoundation(/** @type {!MDCSelectIconAdapter} */ (Object.assign({ + getDefaultFoundation(): MDCSelectIconFoundation { + // tslint:disable:object-literal-sort-keys + return new MDCSelectIconFoundation({ getAttr: (attr) => this.root_.getAttribute(attr), setAttr: (attr, value) => this.root_.setAttribute(attr, value), removeAttr: (attr) => this.root_.removeAttribute(attr), - setContent: (content) => { - this.root_.textContent = content; - }, + setContent: (content) => this.root_.textContent = content, registerInteractionHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), deregisterInteractionHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), notifyIconAction: () => this.emit( - MDCSelectIconFoundation.strings.ICON_EVENT, {} /* evtData */, true /* shouldBubble */), - }))); + MDCSelectIconFoundation.strings.ICON_EVENT, {} /* evtData */, true /* shouldBubble */), + }); + // tslint:enable:object-literal-sort-keys } } -export {MDCSelectIcon, MDCSelectIconFoundation}; +export {MDCSelectIcon as default, MDCSelectIcon}; +export * from './adapter'; +export * from './foundation'; diff --git a/packages/mdc-select/index.js b/packages/mdc-select/index.js deleted file mode 100644 index 27514921e1f..00000000000 --- a/packages/mdc-select/index.js +++ /dev/null @@ -1,706 +0,0 @@ -/** - * @license - * Copyright 2016 Google Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -import {MDCComponent} from '@material/base/component'; -import {MDCFloatingLabel} from '@material/floating-label/index'; -import {MDCLineRipple} from '@material/line-ripple/index'; -import {MDCMenu} from '@material/menu/index.ts'; -import {MDCRipple, MDCRippleFoundation} from '@material/ripple/index'; -import {MDCNotchedOutline} from '@material/notched-outline/index'; -import MDCSelectFoundation from './foundation'; -import {cssClasses, strings} from './constants'; - -/* eslint-disable no-unused-vars */ -import {MDCSelectAdapter, FoundationMapType} from './adapter'; -import {MDCSelectIcon, MDCSelectIconFoundation} from './icon/index'; -import {MDCSelectHelperText, MDCSelectHelperTextFoundation} from './helper-text/index'; -/* eslint-enable no-unused-vars */ - -// Closure has issues with {this as that} syntax. -import * as menuSurfaceConstants from '@material/menu-surface/constants.ts'; -import * as menuConstants from '@material/menu/constants.ts'; - -const VALIDATION_ATTR_WHITELIST = ['required', 'aria-required']; - -/** - * @extends MDCComponent - */ -class MDCSelect extends MDCComponent { - /** - * @param {...?} args - */ - constructor(...args) { - super(...args); - /** @private {?Element} */ - this.nativeControl_; - /** @private {?Element} */ - this.selectedText_; - /** @private {?Element} */ - this.hiddenInput_; - /** @private {?MDCSelectIcon} */ - this.leadingIcon_; - /** @private {?MDCSelectHelperText} */ - this.helperText_; - /** @private {?Element} */ - this.menuElement_; - /** @type {?MDCMenu} */ - this.menu_; - /** @type {?MDCRipple} */ - this.ripple; - /** @private {?MDCLineRipple} */ - this.lineRipple_; - /** @private {?MDCFloatingLabel} */ - this.label_; - /** @private {?MDCNotchedOutline} */ - this.outline_; - /** @private {!Function} */ - this.handleChange_; - /** @private {!Function} */ - this.handleFocus_; - /** @private {!Function} */ - this.handleBlur_; - /** @private {!Function} */ - this.handleClick_; - /** @private {!Function} */ - this.handleKeydown_; - /** @private {!Function} */ - this.handleMenuOpened_; - /** @private {!Function} */ - this.handleMenuClosed_; - /** @private {!Function} */ - this.handleMenuSelected_; - /** @private {boolean} */ - this.menuOpened_ = false; - /** @private {!MutationObserver} */ - this.validationObserver_; - } - - /** - * @param {!Element} root - * @return {!MDCSelect} - */ - static attachTo(root) { - return new MDCSelect(root); - } - - /** - * @return {string} The value of the select. - */ - get value() { - return this.foundation_.getValue(); - } - - /** - * @param {string} value The value to set on the select. - */ - set value(value) { - this.foundation_.setValue(value); - } - - /** - * @return {number} The selected index of the select. - */ - get selectedIndex() { - let selectedIndex; - if (this.menuElement_) { - const selectedEl = /** @type {!HTMLElement} */ (this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)); - selectedIndex = this.menu_.items.indexOf(selectedEl); - } else { - selectedIndex = this.nativeControl_.selectedIndex; - } - return selectedIndex; - } - - /** - * @param {number} selectedIndex The index of the option to be set on the select. - */ - set selectedIndex(selectedIndex) { - this.foundation_.setSelectedIndex(selectedIndex); - } - - /** - * @return {boolean} True if the select is disabled. - */ - get disabled() { - return this.root_.classList.contains(cssClasses.DISABLED) || - (this.nativeControl_ ? this.nativeControl_.disabled : false); - } - - /** - * @param {boolean} disabled Sets the select disabled or enabled. - */ - set disabled(disabled) { - this.foundation_.setDisabled(disabled); - } - - /** - * Sets the aria label of the leading icon. - * @param {string} label - */ - set leadingIconAriaLabel(label) { - this.foundation_.setLeadingIconAriaLabel(label); - } - - /** - * Sets the text content of the leading icon. - * @param {string} content - */ - set leadingIconContent(content) { - this.foundation_.setLeadingIconContent(content); - } - - /** - * Sets the text content of the helper text. - * @param {string} content - */ - set helperTextContent(content) { - this.foundation_.setHelperTextContent(content); - } - - /** - * Sets the current invalid state of the select. - * @param {boolean} isValid - */ - set valid(isValid) { - this.foundation_.setValid(isValid); - } - - /** - * Checks if the select is in a valid state. - * @return {boolean} - */ - get valid() { - return this.foundation_.isValid(); - } - - /** - * Sets the control to the required state. - * @param {boolean} isRequired - */ - set required(isRequired) { - if (this.nativeControl_) { - this.nativeControl_.required = isRequired; - } else { - if (isRequired) { - this.selectedText_.setAttribute('aria-required', isRequired.toString()); - } else { - this.selectedText_.removeAttribute('aria-required'); - } - } - } - - /** - * Returns whether the select is required. - * @return {boolean} - */ - get required() { - if (this.nativeControl_) { - return this.nativeControl_.required; - } else { - return this.selectedText_.getAttribute('aria-required') === 'true'; - } - } - - /** - * Recomputes the outline SVG path for the outline element. - */ - layout() { - this.foundation_.layout(); - } - - - /** - * @param {(function(!Element): !MDCLineRipple)=} lineRippleFactory A function which creates a new MDCLineRipple. - * @param {(function(!Element): !MDCFloatingLabel)=} labelFactory A function which creates a new MDCFloatingLabel. - * @param {(function(!Element): !MDCNotchedOutline)=} outlineFactory A function which creates a new MDCNotchedOutline. - * @param {(function(!Element): !MDCMenu)=} menuFactory A function which creates a new MDCMenu. - * @param {(function(!Element): !MDCSelectIcon)=} iconFactory A function which creates a new MDCSelectIcon. - * @param {(function(!Element): !MDCSelectHelperText)=} helperTextFactory A function which creates a new - * MDCSelectHelperText. - */ - initialize( - labelFactory = (el) => new MDCFloatingLabel(el), - lineRippleFactory = (el) => new MDCLineRipple(el), - outlineFactory = (el) => new MDCNotchedOutline(el), - menuFactory = (el) => new MDCMenu(el), - iconFactory = (el) => new MDCSelectIcon(el), - helperTextFactory = (el) => new MDCSelectHelperText(el)) { - this.nativeControl_ = /** @type {HTMLElement} */ (this.root_.querySelector(strings.NATIVE_CONTROL_SELECTOR)); - this.selectedText_ = /** @type {HTMLElement} */ (this.root_.querySelector(strings.SELECTED_TEXT_SELECTOR)); - - if (this.selectedText_) { - this.enhancedSelectSetup_(menuFactory); - } - - const labelElement = this.root_.querySelector(strings.LABEL_SELECTOR); - if (labelElement) { - this.label_ = labelFactory(labelElement); - } - const lineRippleElement = this.root_.querySelector(strings.LINE_RIPPLE_SELECTOR); - if (lineRippleElement) { - this.lineRipple_ = lineRippleFactory(lineRippleElement); - } - const outlineElement = this.root_.querySelector(strings.OUTLINE_SELECTOR); - if (outlineElement) { - this.outline_ = outlineFactory(outlineElement); - } - - const leadingIcon = this.root_.querySelector(strings.LEADING_ICON_SELECTOR); - if (leadingIcon) { - this.root_.classList.add(cssClasses.WITH_LEADING_ICON); - this.leadingIcon_ = iconFactory(leadingIcon); - - if (this.menuElement_) { - this.menuElement_.classList.add(cssClasses.WITH_LEADING_ICON); - } - } - const element = this.nativeControl_ ? this.nativeControl_ : this.selectedText_; - if (element.hasAttribute(strings.ARIA_CONTROLS)) { - const helperTextElement = document.getElementById(element.getAttribute(strings.ARIA_CONTROLS)); - if (helperTextElement) { - this.helperText_ = helperTextFactory(helperTextElement); - } - } - - if (!this.root_.classList.contains(cssClasses.OUTLINED)) { - this.ripple = this.initRipple_(); - } - - // The required state needs to be sync'd before the mutation observer is added. - this.initialSyncRequiredState_(); - this.addMutationObserverForRequired_(); - } - - /** - * Handles setup for the enhanced menu. - * @private - */ - enhancedSelectSetup_(menuFactory) { - const isDisabled = this.root_.classList.contains(cssClasses.DISABLED); - this.selectedText_.setAttribute('tabindex', isDisabled ? '-1' : '0'); - this.hiddenInput_ = this.root_.querySelector(strings.HIDDEN_INPUT_SELECTOR); - this.menuElement_ = /** @type {HTMLElement} */ (this.root_.querySelector(strings.MENU_SELECTOR)); - this.menu_ = menuFactory(this.menuElement_); - this.menu_.hoistMenuToBody(); - this.menu_.setAnchorElement(this.root_); - this.menu_.setAnchorCorner(menuSurfaceConstants.Corner.BOTTOM_START); - this.menu_.wrapFocus = false; - } - - /** - * @private - * @return {!MDCRipple} - */ - initRipple_() { - const element = this.nativeControl_ ? this.nativeControl_ : this.selectedText_; - const adapter = Object.assign(MDCRipple.createAdapter(this), { - registerInteractionHandler: (type, handler) => element.addEventListener(type, handler), - deregisterInteractionHandler: (type, handler) => element.removeEventListener(type, handler), - }); - const foundation = new MDCRippleFoundation(adapter); - return new MDCRipple(this.root_, foundation); - } - - /** - * Initializes the select's event listeners and internal state based - * on the environment's state. - */ - initialSyncWithDOM() { - this.handleChange_ = () => this.foundation_.handleChange(/* didChange */ true); - this.handleFocus_ = () => this.foundation_.handleFocus(); - this.handleBlur_ = () => this.foundation_.handleBlur(); - this.handleClick_ = (evt) => { - if (this.selectedText_) this.selectedText_.focus(); - this.foundation_.handleClick(this.getNormalizedXCoordinate_(evt)); - }; - this.handleKeydown_ = (evt) => this.foundation_.handleKeydown(evt); - this.handleMenuSelected_ = (evtData) => this.selectedIndex = evtData.detail.index; - this.handleMenuOpened_ = () => { - // Menu should open to the last selected element. - if (this.selectedIndex >= 0) { - this.menu_.items[this.selectedIndex].focus(); - } - }; - this.handleMenuClosed_ = () => { - // menuOpened_ is used to track the state of the menu opening or closing since the menu.open function - // will return false if the menu is still closing and this method listens to the closed event which - // occurs after the menu is already closed. - this.menuOpened_ = false; - this.selectedText_.removeAttribute('aria-expanded'); - if (document.activeElement !== this.selectedText_) { - this.foundation_.handleBlur(); - } - }; - - const element = this.nativeControl_ ? this.nativeControl_ : this.selectedText_; - - element.addEventListener('change', this.handleChange_); - element.addEventListener('focus', this.handleFocus_); - element.addEventListener('blur', this.handleBlur_); - - ['mousedown', 'touchstart'].forEach((evtType) => { - element.addEventListener(evtType, this.handleClick_); - }); - - if (this.menuElement_) { - this.selectedText_.addEventListener('keydown', this.handleKeydown_); - this.menu_.listen(menuSurfaceConstants.strings.CLOSED_EVENT, this.handleMenuClosed_); - this.menu_.listen(menuSurfaceConstants.strings.OPENED_EVENT, this.handleMenuOpened_); - this.menu_.listen(menuConstants.strings.SELECTED_EVENT, this.handleMenuSelected_); - - if (this.hiddenInput_ && this.hiddenInput_.value) { - // If the hidden input already has a value, use it to restore the select's value. - // This can happen e.g. if the user goes back or (in some browsers) refreshes the page. - const enhancedAdapterMethods = this.getEnhancedSelectAdapterMethods_(); - enhancedAdapterMethods.setValue(this.hiddenInput_.value); - } else if (this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)) { - // If an element is selected, the select should set the initial selected text. - const enhancedAdapterMethods = this.getEnhancedSelectAdapterMethods_(); - enhancedAdapterMethods.setValue(enhancedAdapterMethods.getValue()); - } - } - - // Initially sync floating label - this.foundation_.handleChange(/* didChange */ false); - - if (this.root_.classList.contains(cssClasses.DISABLED) - || (this.nativeControl_ && this.nativeControl_.disabled)) { - this.disabled = true; - } - } - - destroy() { - const element = this.nativeControl_ ? this.nativeControl_ : this.selectedText_; - - element.removeEventListener('change', this.handleChange_); - element.removeEventListener('focus', this.handleFocus_); - element.removeEventListener('blur', this.handleBlur_); - element.removeEventListener('keydown', this.handleKeydown_); - ['mousedown', 'touchstart'].forEach((evtType) => { - element.removeEventListener(evtType, this.handleClick_); - }); - - if (this.menu_) { - this.menu_.unlisten(menuSurfaceConstants.strings.CLOSED_EVENT, this.handleMenuClosed_); - this.menu_.unlisten(menuSurfaceConstants.strings.OPENED_EVENT, this.handleMenuOpened_); - this.menu_.unlisten(menuConstants.strings.SELECTED_EVENT, this.handleMenuSelected_); - this.menu_.destroy(); - } - - if (this.ripple) { - this.ripple.destroy(); - } - if (this.outline_) { - this.outline_.destroy(); - } - if (this.leadingIcon_) { - this.leadingIcon_.destroy(); - } - if (this.helperText_) { - this.helperText_.destroy(); - } - if (this.validationObserver_) { - this.validationObserver_.disconnect(); - } - - super.destroy(); - } - - /** - * @return {!MDCSelectFoundation} - */ - getDefaultFoundation() { - return new MDCSelectFoundation( - /** @type {!MDCSelectAdapter} */ (Object.assign( - this.nativeControl_ ? this.getNativeSelectAdapterMethods_() : this.getEnhancedSelectAdapterMethods_(), - this.getCommonAdapterMethods_(), - this.getOutlineAdapterMethods_(), - this.getLabelAdapterMethods_()) - ), - this.getFoundationMap_() - ); - } - - /** - * @return {!{ - * getValue: function(): string, - * setValue: function(string): string, - * openMenu: function(): void, - * closeMenu: function(): void, - * isMenuOpen: function(): boolean, - * setSelectedIndex: function(number): void, - * setDisabled: function(boolean): void - * }} - * @private - */ - getNativeSelectAdapterMethods_() { - return { - getValue: () => this.nativeControl_.value, - setValue: (value) => this.nativeControl_.value = value, - openMenu: () => {}, - closeMenu: () => {}, - isMenuOpen: () => false, - setSelectedIndex: (index) => { - this.nativeControl_.selectedIndex = index; - }, - setDisabled: (isDisabled) => this.nativeControl_.disabled = isDisabled, - setValid: (isValid) => { - isValid ? this.root_.classList.remove(cssClasses.INVALID) : this.root_.classList.add(cssClasses.INVALID); - }, - checkValidity: () => this.nativeControl_.checkValidity(), - }; - } - - /** - * @return {!{ - * getValue: function(): string, - * setValue: function(string): string, - * openMenu: function(): void, - * closeMenu: function(): void, - * isMenuOpen: function(): boolean, - * setSelectedIndex: function(number): void, - * setDisabled: function(boolean): void - * }} - * @private - */ - getEnhancedSelectAdapterMethods_() { - return { - getValue: () => { - const listItem = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR); - if (listItem && listItem.hasAttribute(strings.ENHANCED_VALUE_ATTR)) { - return listItem.getAttribute(strings.ENHANCED_VALUE_ATTR); - } - return ''; - }, - setValue: (value) => { - const element = - /** @type {HTMLElement} */ (this.menuElement_.querySelector(`[${strings.ENHANCED_VALUE_ATTR}="${value}"]`)); - this.setEnhancedSelectedIndex_(element ? this.menu_.items.indexOf(element) : -1); - }, - openMenu: () => { - if (this.menu_ && !this.menu_.open) { - this.menu_.open = true; - this.menuOpened_ = true; - this.selectedText_.setAttribute('aria-expanded', 'true'); - } - }, - closeMenu: () => { - if (this.menu_ && this.menu_.open) { - this.menu_.open = false; - } - }, - isMenuOpen: () => this.menu_ && this.menuOpened_, - setSelectedIndex: (index) => { - this.setEnhancedSelectedIndex_(index); - }, - setDisabled: (isDisabled) => { - this.selectedText_.setAttribute('tabindex', isDisabled ? '-1' : '0'); - this.selectedText_.setAttribute('aria-disabled', isDisabled.toString()); - if (this.hiddenInput_) { - this.hiddenInput_.disabled = isDisabled; - } - }, - checkValidity: () => { - const classList = this.root_.classList; - if (classList.contains(cssClasses.REQUIRED) && !classList.contains(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 !== -1 && (this.selectedIndex !== 0 || this.value); - } else { - return true; - } - }, - setValid: (isValid) => { - this.selectedText_.setAttribute('aria-invalid', (!isValid).toString()); - isValid ? this.root_.classList.remove(cssClasses.INVALID) : this.root_.classList.add(cssClasses.INVALID); - }, - }; - } - - /** - * @return {!{ - * addClass: function(string): void, - * removeClass: function(string): void, - * hasClass: function(string): void, - * setRippleCenter: function(number): void, - * activateBottomLine: function(): void, - * deactivateBottomLine: function(): void, - * notifyChange: function(string): void - * }} - * @private - */ - getCommonAdapterMethods_() { - return { - addClass: (className) => this.root_.classList.add(className), - removeClass: (className) => this.root_.classList.remove(className), - hasClass: (className) => this.root_.classList.contains(className), - setRippleCenter: (normalizedX) => this.lineRipple_ && this.lineRipple_.setRippleCenter(normalizedX), - activateBottomLine: () => this.lineRipple_ && this.lineRipple_.activate(), - deactivateBottomLine: () => this.lineRipple_ && this.lineRipple_.deactivate(), - notifyChange: (value) => { - const index = this.selectedIndex; - this.emit(strings.CHANGE_EVENT, {value, index}, true /* shouldBubble */); - }, - }; - } - - /** - * @return {!{ - * hasOutline: function(): boolean, - * notchOutline: function(number, boolean): undefined, - * closeOutline: function(): undefined, - * }} - */ - getOutlineAdapterMethods_() { - return { - hasOutline: () => !!this.outline_, - notchOutline: (labelWidth) => { - if (this.outline_) { - this.outline_.notch(labelWidth); - } - }, - closeOutline: () => { - if (this.outline_) { - this.outline_.closeNotch(); - } - }, - }; - } - - /** - * @return {!{ - * floatLabel: function(boolean): undefined, - * getLabelWidth: function(): number, - * }} - */ - getLabelAdapterMethods_() { - return { - floatLabel: (shouldFloat) => { - if (this.label_) { - this.label_.float(shouldFloat); - } - }, - getLabelWidth: () => { - return this.label_ ? this.label_.getWidth() : 0; - }, - }; - } - - /** - * Calculates where the line ripple should start based on the x coordinate within the component. - * @param {!(MouseEvent|TouchEvent)} evt - * @return {number} normalizedX - */ - getNormalizedXCoordinate_(evt) { - const targetClientRect = evt.target.getBoundingClientRect(); - const xCoordinate = evt.clientX; - return xCoordinate - targetClientRect.left; - } - - /** - * Returns a map of all subcomponents to subfoundations. - * @return {!FoundationMapType} - */ - getFoundationMap_() { - return { - leadingIcon: this.leadingIcon_ ? this.leadingIcon_.foundation : undefined, - helperText: this.helperText_ ? this.helperText_.foundation : undefined, - }; - } - - /** - * Sets the selected index of the enhanced menu. - * @param {number} index - * @private - */ - setEnhancedSelectedIndex_(index) { - const selectedItem = this.menu_.items[index]; - this.selectedText_.textContent = selectedItem ? selectedItem.textContent.trim() : ''; - const previouslySelected = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR); - - if (previouslySelected) { - previouslySelected.classList.remove(cssClasses.SELECTED_ITEM_CLASS); - previouslySelected.removeAttribute(strings.ARIA_SELECTED_ATTR); - } - - if (selectedItem) { - selectedItem.classList.add(cssClasses.SELECTED_ITEM_CLASS); - selectedItem.setAttribute(strings.ARIA_SELECTED_ATTR, 'true'); - } - - // Synchronize hidden input's value with data-value attribute of selected item. - // This code path is also followed when setting value directly, so this covers all cases. - if (this.hiddenInput_) { - this.hiddenInput_.value = selectedItem ? selectedItem.getAttribute(strings.ENHANCED_VALUE_ATTR) || '' : ''; - } - - this.layout(); - } - - initialSyncRequiredState_() { - const element = this.nativeControl_ ? this.nativeControl_ : this.selectedText_; - const isRequired = element.required || element.getAttribute('aria-required') === 'true' - || this.root_.classList.contains(cssClasses.REQUIRED); - if (isRequired) { - if (this.nativeControl_) { - this.nativeControl_.required = true; - } else { - this.selectedText_.setAttribute('aria-required', 'true'); - } - this.root_.classList.add(cssClasses.REQUIRED); - } - } - - addMutationObserverForRequired_() { - const observerHandler = (attributesList) => { - attributesList.some((attributeName) => { - if (VALIDATION_ATTR_WHITELIST.indexOf(attributeName) > -1) { - if (this.selectedText_) { - if (this.selectedText_.getAttribute('aria-required') === 'true') { - this.root_.classList.add(cssClasses.REQUIRED); - } else { - this.root_.classList.remove(cssClasses.REQUIRED); - } - } else { - if (this.nativeControl_.required) { - this.root_.classList.add(cssClasses.REQUIRED); - } else { - this.root_.classList.remove(cssClasses.REQUIRED); - } - } - return true; - } - }); - }; - - const getAttributesList = (mutationsList) => mutationsList.map((mutation) => mutation.attributeName); - const observer = new MutationObserver((mutationsList) => observerHandler(getAttributesList(mutationsList))); - const element = this.nativeControl_ ? this.nativeControl_ : this.selectedText_; - observer.observe(element, {attributes: true}); - this.validationObserver_ = observer; - }; -} - -export {MDCSelect, MDCSelectFoundation, - MDCSelectHelperText, MDCSelectHelperTextFoundation, - MDCSelectIcon, MDCSelectIconFoundation}; diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts new file mode 100644 index 00000000000..f2ba3552cbc --- /dev/null +++ b/packages/mdc-select/index.ts @@ -0,0 +1,597 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import {CustomEventListener, MDCComponent} from '@material/base/index'; +import {EventType, SpecificEventListener} from '@material/base/index'; +import {MDCFloatingLabel} from '@material/floating-label/index'; +import {MDCLineRipple} from '@material/line-ripple/index'; +import * as menuSurfaceConstants from '@material/menu-surface/constants'; +import * as menuConstants from '@material/menu/constants'; +import {MDCMenu, MenuItemEvent} from '@material/menu/index'; +import {MDCNotchedOutline} from '@material/notched-outline/index'; +import {MDCRipple, MDCRippleFoundation, RippleCapableSurface} from '@material/ripple/index'; +import {cssClasses, strings} from './constants'; +import {MDCSelectFoundation} from './foundation'; +import {MDCSelectHelperText} from './helper-text'; +import {MDCSelectIcon} from './icon'; +import { + FoundationMapType, + HelperTextFactory, + IconFactory, + LabelFactory, + LineRippleFactory, MenuFactory, + OutlineFactory, SelectEventDetail, +} from './types'; + +const VALIDATION_ATTR_WHITELIST = ['required', 'aria-required']; +const MOUSEDOWN_TOUCHSTART_EVENTS: EventType[] = ['mousedown', 'touchstart']; + +class MDCSelect extends MDCComponent implements RippleCapableSurface { + static attachTo(root: Element): MDCSelect { + return new MDCSelect(root); + } + + // Public visibility for this property is required by RippleCapableSurface. + root_!: HTMLElement; // assigned in MDCComponent constructor + + private menu_!: MDCMenu | null; // assigned in enhancedSelectSetup_() + private menuOpened_ = false; + + // Exactly one of these fields must be non-null. + private nativeControl_!: HTMLSelectElement | null; + private selectedText_!: HTMLElement | null; + + private hiddenInput_!: HTMLInputElement | null; + private leadingIcon_!: MDCSelectIcon | null; + private helperText_!: MDCSelectHelperText | null; + private menuElement_!: Element | null; + private ripple!: MDCRipple | null; + private lineRipple_!: MDCLineRipple | null; + private label_!: MDCFloatingLabel | null; + private outline_!: MDCNotchedOutline | null; + private handleChange_!: SpecificEventListener<'change'>; // assigned in initialize() + private handleFocus_!: SpecificEventListener<'focus'>; // assigned in initialize() + private handleBlur_!: SpecificEventListener<'blur'>; // assigned in initialize() + private handleClick_!: SpecificEventListener<'click'>; // assigned in initialize() + private handleKeydown_!: SpecificEventListener<'keydown'>; // assigned in initialize() + private handleMenuOpened_!: EventListener; // assigned in initialize() + private handleMenuClosed_!: EventListener; // assigned in initialize() + private handleMenuSelected_!: CustomEventListener; // assigned in initialize() + private validationObserver_!: MutationObserver; // assigned in initialize() + + get value(): string { + return this.foundation_.getValue(); + } + + set value(value: string) { + this.foundation_.setValue(value); + } + + get selectedIndex(): number { + let selectedIndex = -1; + if (this.menuElement_ && this.menu_) { + const selectedEl = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)!; + selectedIndex = this.menu_.items.indexOf(selectedEl); + } else if (this.nativeControl_) { + selectedIndex = this.nativeControl_.selectedIndex; + } + return selectedIndex; + } + + set selectedIndex(selectedIndex: number) { + this.foundation_.setSelectedIndex(selectedIndex); + } + + get disabled(): boolean { + return this.root_.classList.contains(cssClasses.DISABLED) || + (this.nativeControl_ ? this.nativeControl_.disabled : false); + } + + set disabled(disabled: boolean) { + this.foundation_.setDisabled(disabled); + } + + set leadingIconAriaLabel(label: string) { + this.foundation_.setLeadingIconAriaLabel(label); + } + + /** + * Sets the text content of the leading icon. + */ + set leadingIconContent(content: string) { + this.foundation_.setLeadingIconContent(content); + } + + /** + * Sets the text content of the helper text. + */ + set helperTextContent(content: string) { + this.foundation_.setHelperTextContent(content); + } + + /** + * Sets the current invalid state of the select. + */ + set valid(isValid: boolean) { + this.foundation_.setValid(isValid); + } + + /** + * Checks if the select is in a valid state. + */ + get valid(): boolean { + return this.foundation_.isValid(); + } + + /** + * Sets the control to the required state. + */ + set required(isRequired: boolean) { + if (this.nativeControl_) { + this.nativeControl_.required = isRequired; + } else { + if (isRequired) { + this.selectedText_!.setAttribute('aria-required', isRequired.toString()); + } else { + this.selectedText_!.removeAttribute('aria-required'); + } + } + } + + /** + * Returns whether the select is required. + */ + get required(): boolean { + if (this.nativeControl_) { + return this.nativeControl_.required; + } else { + return this.selectedText_!.getAttribute('aria-required') === 'true'; + } + } + + /** + * Recomputes the outline SVG path for the outline element. + */ + layout() { + this.foundation_.layout(); + } + + initialize( + labelFactory: LabelFactory = (el) => new MDCFloatingLabel(el), + lineRippleFactory: LineRippleFactory = (el) => new MDCLineRipple(el), + outlineFactory: OutlineFactory = (el) => new MDCNotchedOutline(el), + menuFactory: MenuFactory = (el) => new MDCMenu(el), + iconFactory: IconFactory = (el) => new MDCSelectIcon(el), + helperTextFactory: HelperTextFactory = (el) => new MDCSelectHelperText(el)) { + this.nativeControl_ = this.root_.querySelector(strings.NATIVE_CONTROL_SELECTOR); + this.selectedText_ = this.root_.querySelector(strings.SELECTED_TEXT_SELECTOR); + + if (this.selectedText_) { + this.enhancedSelectSetup_(menuFactory); + } + + const labelElement = this.root_.querySelector(strings.LABEL_SELECTOR); + this.label_ = labelElement ? labelFactory(labelElement) : null; + + const lineRippleElement = this.root_.querySelector(strings.LINE_RIPPLE_SELECTOR); + this.lineRipple_ = lineRippleElement ? lineRippleFactory(lineRippleElement) : null; + + const outlineElement = this.root_.querySelector(strings.OUTLINE_SELECTOR); + this.outline_ = outlineElement ? outlineFactory(outlineElement) : null; + + const leadingIcon = this.root_.querySelector(strings.LEADING_ICON_SELECTOR); + if (leadingIcon) { + this.root_.classList.add(cssClasses.WITH_LEADING_ICON); + this.leadingIcon_ = iconFactory(leadingIcon); + + if (this.menuElement_) { + this.menuElement_.classList.add(cssClasses.WITH_LEADING_ICON); + } + } + + // At least one of these elements must be non-null. + const element = this.nativeControl_ || this.selectedText_!; + if (element.hasAttribute(strings.ARIA_CONTROLS)) { + const helperTextElement = document.getElementById(element.getAttribute(strings.ARIA_CONTROLS)!); + if (helperTextElement) { + this.helperText_ = helperTextFactory(helperTextElement); + } + } + + if (!this.root_.classList.contains(cssClasses.OUTLINED)) { + this.ripple = this.initRipple_(); + } + + // The required state needs to be sync'd before the mutation observer is added. + this.initialSyncRequiredState_(); + this.addMutationObserverForRequired_(); + } + + /** + * Initializes the select's event listeners and internal state based + * on the environment's state. + */ + initialSyncWithDOM() { + this.handleChange_ = () => this.foundation_.handleChange(/* didChange */ true); + this.handleFocus_ = () => this.foundation_.handleFocus(); + this.handleBlur_ = () => this.foundation_.handleBlur(); + this.handleClick_ = (evt) => { + if (this.selectedText_) { + this.selectedText_.focus(); + } + this.foundation_.handleClick(this.getNormalizedXCoordinate_(evt)); + }; + this.handleKeydown_ = (evt) => this.foundation_.handleKeydown(evt); + this.handleMenuSelected_ = (evtData) => this.selectedIndex = evtData.detail.index; + this.handleMenuOpened_ = () => { + // Menu should open to the last selected element. + if (this.selectedIndex >= 0) { + this.menu_!.items[this.selectedIndex].focus(); + } + }; + this.handleMenuClosed_ = () => { + // menuOpened_ is used to track the state of the menu opening or closing since the menu.open function + // will return false if the menu is still closing and this method listens to the closed event which + // occurs after the menu is already closed. + this.menuOpened_ = false; + this.selectedText_!.removeAttribute('aria-expanded'); + if (document.activeElement !== this.selectedText_) { + this.foundation_.handleBlur(); + } + }; + + // One of these elements must be non-null. + const element = this.nativeControl_ || this.selectedText_!; + + element.addEventListener('change', this.handleChange_); + element.addEventListener('focus', this.handleFocus_); + element.addEventListener('blur', this.handleBlur_); + + MOUSEDOWN_TOUCHSTART_EVENTS.forEach((evtType) => { + element.addEventListener(evtType, this.handleClick_ as EventListener); + }); + + if (this.menuElement_) { + this.selectedText_!.addEventListener('keydown', this.handleKeydown_); + this.menu_!.listen(menuSurfaceConstants.strings.CLOSED_EVENT, this.handleMenuClosed_); + this.menu_!.listen(menuSurfaceConstants.strings.OPENED_EVENT, this.handleMenuOpened_); + this.menu_!.listen(menuConstants.strings.SELECTED_EVENT, this.handleMenuSelected_); + + if (this.hiddenInput_ && this.hiddenInput_.value) { + // If the hidden input already has a value, use it to restore the select's value. + // This can happen e.g. if the user goes back or (in some browsers) refreshes the page. + const enhancedAdapterMethods = this.getEnhancedSelectAdapterMethods_(); + enhancedAdapterMethods.setValue(this.hiddenInput_.value); + } else if (this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)) { + // If an element is selected, the select should set the initial selected text. + const enhancedAdapterMethods = this.getEnhancedSelectAdapterMethods_(); + enhancedAdapterMethods.setValue(enhancedAdapterMethods.getValue()); + } + } + + // Initially sync floating label + this.foundation_.handleChange(/* didChange */ false); + + if (this.root_.classList.contains(cssClasses.DISABLED) + || (this.nativeControl_ && this.nativeControl_.disabled)) { + this.disabled = true; + } + } + + destroy() { + // One of these elements must be non-null. + const element = this.nativeControl_ || this.selectedText_!; + + element.removeEventListener('change', this.handleChange_); + element.removeEventListener('focus', this.handleFocus_); + element.removeEventListener('blur', this.handleBlur_); + element.removeEventListener('keydown', this.handleKeydown_); + MOUSEDOWN_TOUCHSTART_EVENTS.forEach((evtType) => { + element.removeEventListener(evtType, this.handleClick_ as EventListener); + }); + + if (this.menu_) { + this.menu_.unlisten(menuSurfaceConstants.strings.CLOSED_EVENT, this.handleMenuClosed_); + this.menu_.unlisten(menuSurfaceConstants.strings.OPENED_EVENT, this.handleMenuOpened_); + this.menu_.unlisten(menuConstants.strings.SELECTED_EVENT, this.handleMenuSelected_); + this.menu_.destroy(); + } + + if (this.ripple) { + this.ripple.destroy(); + } + if (this.outline_) { + this.outline_.destroy(); + } + if (this.leadingIcon_) { + this.leadingIcon_.destroy(); + } + if (this.helperText_) { + this.helperText_.destroy(); + } + if (this.validationObserver_) { + this.validationObserver_.disconnect(); + } + + super.destroy(); + } + + getDefaultFoundation(): MDCSelectFoundation { + return new MDCSelectFoundation({ + ...(this.nativeControl_ ? this.getNativeSelectAdapterMethods_() : this.getEnhancedSelectAdapterMethods_()), + ...this.getCommonAdapterMethods_(), + ...this.getOutlineAdapterMethods_(), + ...this.getLabelAdapterMethods_(), + }, + this.getFoundationMap_(), + ); + } + + /** + * Handles setup for the enhanced menu. + */ + private enhancedSelectSetup_(menuFactory: MenuFactory) { + const isDisabled = this.root_.classList.contains(cssClasses.DISABLED); + this.selectedText_!.setAttribute('tabindex', isDisabled ? '-1' : '0'); + this.hiddenInput_ = this.root_.querySelector(strings.HIDDEN_INPUT_SELECTOR); + this.menuElement_ = this.root_.querySelector(strings.MENU_SELECTOR)!; + this.menu_ = menuFactory(this.menuElement_); + this.menu_.hoistMenuToBody(); + this.menu_.setAnchorElement(this.root_); + this.menu_.setAnchorCorner(menuSurfaceConstants.Corner.BOTTOM_START); + this.menu_.wrapFocus = false; + } + + private initRipple_(): MDCRipple { + // At least one of the elements must be non-null. + const element = this.nativeControl_ || this.selectedText_!; + + // tslint:disable:object-literal-sort-keys + const foundation = new MDCRippleFoundation({ + ...MDCRipple.createAdapter(this), + registerInteractionHandler: (evtType: E, handler: SpecificEventListener) => { + element.addEventListener(evtType, handler); + }, + deregisterInteractionHandler: (evtType: E, handler: SpecificEventListener) => { + element.removeEventListener(evtType, handler); + }, + }); + // tslint:enable:object-literal-sort-keys + + return new MDCRipple(this.root_, foundation); + } + + private getNativeSelectAdapterMethods_() { + // tslint:disable:object-literal-sort-keys + return { + getValue: () => this.nativeControl_!.value, + setValue: (value: string) => this.nativeControl_!.value = value, + openMenu: () => undefined, + closeMenu: () => undefined, + isMenuOpen: () => false, + setSelectedIndex: (index: number) => this.nativeControl_!.selectedIndex = index, + setDisabled: (isDisabled: boolean) => this.nativeControl_!.disabled = isDisabled, + setValid: (isValid: boolean) => { + if (isValid) { + this.root_.classList.remove(cssClasses.INVALID); + } else { + this.root_.classList.add(cssClasses.INVALID); + } + }, + checkValidity: () => this.nativeControl_!.checkValidity(), + }; + // tslint:enable:object-literal-sort-keys + } + + private getEnhancedSelectAdapterMethods_() { + // tslint:disable:object-literal-sort-keys + return { + getValue: () => { + const listItem = this.menuElement_!.querySelector(strings.SELECTED_ITEM_SELECTOR); + if (listItem && listItem.hasAttribute(strings.ENHANCED_VALUE_ATTR)) { + return listItem.getAttribute(strings.ENHANCED_VALUE_ATTR) || ''; + } + return ''; + }, + setValue: (value: string) => { + const element = + this.menuElement_!.querySelector(`[${strings.ENHANCED_VALUE_ATTR}="${value}"]`); + this.setEnhancedSelectedIndex_(element ? this.menu_!.items.indexOf(element) : -1); + }, + openMenu: () => { + if (this.menu_ && !this.menu_.open) { + this.menu_.open = true; + this.menuOpened_ = true; + this.selectedText_!.setAttribute('aria-expanded', 'true'); + } + }, + closeMenu: () => { + if (this.menu_ && this.menu_.open) { + this.menu_.open = false; + } + }, + isMenuOpen: () => Boolean(this.menu_) && this.menuOpened_, + setSelectedIndex: (index: number) => this.setEnhancedSelectedIndex_(index), + setDisabled: (isDisabled: boolean) => { + this.selectedText_!.setAttribute('tabindex', isDisabled ? '-1' : '0'); + this.selectedText_!.setAttribute('aria-disabled', isDisabled.toString()); + if (this.hiddenInput_) { + this.hiddenInput_.disabled = isDisabled; + } + }, + checkValidity: () => { + const classList = this.root_.classList; + if (classList.contains(cssClasses.REQUIRED) && !classList.contains(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 !== -1 && (this.selectedIndex !== 0 || Boolean(this.value)); + } else { + return true; + } + }, + setValid: (isValid: boolean) => { + this.selectedText_!.setAttribute('aria-invalid', (!isValid).toString()); + if (isValid) { + this.root_.classList.remove(cssClasses.INVALID); + } else { + this.root_.classList.add(cssClasses.INVALID); + } + }, + }; + // tslint:enable:object-literal-sort-keys + } + + private getCommonAdapterMethods_() { + // tslint:disable:object-literal-sort-keys + return { + addClass: (className: string) => this.root_.classList.add(className), + removeClass: (className: string) => this.root_.classList.remove(className), + hasClass: (className: string) => this.root_.classList.contains(className), + setRippleCenter: (normalizedX: number) => this.lineRipple_ && this.lineRipple_.setRippleCenter(normalizedX), + activateBottomLine: () => this.lineRipple_ && this.lineRipple_.activate(), + deactivateBottomLine: () => this.lineRipple_ && this.lineRipple_.deactivate(), + notifyChange: (value: string) => { + const index = this.selectedIndex; + this.emit(strings.CHANGE_EVENT, {value, index}, true /* shouldBubble */); + }, + }; + // tslint:enable:object-literal-sort-keys + } + + private getOutlineAdapterMethods_() { + // tslint:disable:object-literal-sort-keys + return { + hasOutline: () => Boolean(this.outline_), + notchOutline: (labelWidth: number) => this.outline_ && this.outline_.notch(labelWidth), + closeOutline: () => this.outline_ && this.outline_.closeNotch(), + }; + // tslint:enable:object-literal-sort-keys + } + + private getLabelAdapterMethods_() { + return { + floatLabel: (shouldFloat: boolean) => this.label_ && this.label_.float(shouldFloat), + getLabelWidth: () => this.label_ ? this.label_.getWidth() : 0, + }; + } + + /** + * Calculates where the line ripple should start based on the x coordinate within the component. + */ + private getNormalizedXCoordinate_(evt: MouseEvent): number { + const targetClientRect = (evt.target as Element).getBoundingClientRect(); + const xCoordinate = evt.clientX; // TODO(acdvorak): How should this be typed? + return xCoordinate - targetClientRect.left; + } + + /** + * Returns a map of all subcomponents to subfoundations. + */ + private getFoundationMap_(): Partial { + return { + helperText: this.helperText_ ? this.helperText_.foundation : undefined, + leadingIcon: this.leadingIcon_ ? this.leadingIcon_.foundation : undefined, + }; + } + + private setEnhancedSelectedIndex_(index: number) { + const selectedItem = this.menu_!.items[index]; + this.selectedText_!.textContent = selectedItem ? selectedItem.textContent!.trim() : ''; + const previouslySelected = this.menuElement_!.querySelector(strings.SELECTED_ITEM_SELECTOR); + + if (previouslySelected) { + previouslySelected.classList.remove(cssClasses.SELECTED_ITEM_CLASS); + previouslySelected.removeAttribute(strings.ARIA_SELECTED_ATTR); + } + + if (selectedItem) { + selectedItem.classList.add(cssClasses.SELECTED_ITEM_CLASS); + selectedItem.setAttribute(strings.ARIA_SELECTED_ATTR, 'true'); + } + + // Synchronize hidden input's value with data-value attribute of selected item. + // This code path is also followed when setting value directly, so this covers all cases. + if (this.hiddenInput_) { + this.hiddenInput_.value = selectedItem ? selectedItem.getAttribute(strings.ENHANCED_VALUE_ATTR) || '' : ''; + } + + this.layout(); + } + + private initialSyncRequiredState_() { + const element = this.nativeControl_ || this.selectedText_!; + const isRequired = (element as HTMLSelectElement).required || element.getAttribute('aria-required') === 'true' + || this.root_.classList.contains(cssClasses.REQUIRED); + if (isRequired) { + if (this.nativeControl_) { + this.nativeControl_.required = true; + } else { + this.selectedText_!.setAttribute('aria-required', 'true'); + } + this.root_.classList.add(cssClasses.REQUIRED); + } + } + + private addMutationObserverForRequired_() { + const observerHandler = (attributesList: string[]) => { + attributesList.some((attributeName) => { + if (VALIDATION_ATTR_WHITELIST.indexOf(attributeName) === -1) { + return false; + } + + if (this.selectedText_) { + if (this.selectedText_.getAttribute('aria-required') === 'true') { + this.root_.classList.add(cssClasses.REQUIRED); + } else { + this.root_.classList.remove(cssClasses.REQUIRED); + } + } else { + if (this.nativeControl_!.required) { + this.root_.classList.add(cssClasses.REQUIRED); + } else { + this.root_.classList.remove(cssClasses.REQUIRED); + } + } + + return true; + }); + }; + + const getAttributesList = (mutationsList: MutationRecord[]): string[] => { + return mutationsList + .map((mutation) => mutation.attributeName) + .filter((attributeName) => attributeName) as string[]; + }; + const observer = new MutationObserver((mutationsList) => observerHandler(getAttributesList(mutationsList))); + const element = this.nativeControl_ || this.selectedText_!; + observer.observe(element, {attributes: true}); + this.validationObserver_ = observer; + } +} + +export {MDCSelect as default, MDCSelect}; +export * from './helper-text'; +export * from './icon'; +export * from './adapter'; +export * from './foundation'; +export * from './types'; diff --git a/packages/mdc-select/types.ts b/packages/mdc-select/types.ts new file mode 100644 index 00000000000..28ac5ad355d --- /dev/null +++ b/packages/mdc-select/types.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2019 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import {MDCFloatingLabel} from '@material/floating-label/index'; +import {MDCLineRipple} from '@material/line-ripple/index'; +import {MDCMenu} from '@material/menu/index'; +import {MDCNotchedOutline} from '@material/notched-outline/index'; +import {MDCSelectHelperTextFoundation} from './helper-text/index'; +import {MDCSelectIconFoundation} from './icon/index'; + +export interface FoundationMapType { + leadingIcon: MDCSelectIconFoundation; + helperText: MDCSelectHelperTextFoundation; +} + +export interface SelectEvent extends Event { + detail: SelectEventDetail; +} + +export interface SelectEventDetail { + value: string; + index: number; +} + +export type LineRippleFactory = (el: Element) => MDCLineRipple; +export type HelperTextFactory = (el: Element) => MDCTextFieldHelperText; +export type MenuFactory = (el: Element) => MDCMenu; +export type IconFactory = (el: Element) => MDCTextFieldIcon; +export type LabelFactory = (el: Element) => MDCFloatingLabel; +export type OutlineFactory = (el: Element) => MDCNotchedOutline; From 135e09d8ce10ac1be541df55cf77751faaa8fa72 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Fri, 8 Feb 2019 20:25:08 -0800 Subject: [PATCH 02/16] WIP: Add comment --- packages/mdc-select/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mdc-select/types.ts b/packages/mdc-select/types.ts index 28ac5ad355d..977087c54f7 100644 --- a/packages/mdc-select/types.ts +++ b/packages/mdc-select/types.ts @@ -42,6 +42,7 @@ export interface SelectEventDetail { index: number; } +// TODO(acdvorak): Every component should export its own factory and event types. export type LineRippleFactory = (el: Element) => MDCLineRipple; export type HelperTextFactory = (el: Element) => MDCTextFieldHelperText; export type MenuFactory = (el: Element) => MDCMenu; From bc8bf0d145cd3655c9bbfa71877eea3c9f169767 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Sat, 9 Feb 2019 09:39:44 -0800 Subject: [PATCH 03/16] WIP: Fix compiler errors --- packages/mdc-select/foundation.ts | 2 +- packages/mdc-select/index.ts | 3 +-- packages/mdc-select/package.json | 1 + packages/mdc-select/types.ts | 12 +++++------- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/mdc-select/foundation.ts b/packages/mdc-select/foundation.ts index b3b5aa93051..4121f5245c9 100644 --- a/packages/mdc-select/foundation.ts +++ b/packages/mdc-select/foundation.ts @@ -25,9 +25,9 @@ import {MDCFoundation} from '@material/base/foundation'; import {MDCSelectAdapter} from './adapter'; import {cssClasses, numbers, strings} from './constants'; -import {FoundationMapType} from './types'; import {MDCSelectHelperTextFoundation} from './helper-text/index'; import {MDCSelectIconFoundation} from './icon/index'; +import {FoundationMapType} from './types'; class MDCSelectFoundation extends MDCFoundation { static get cssClasses() { diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index f2ba3552cbc..0a591525f00 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -36,8 +36,7 @@ import {MDCSelectHelperText} from './helper-text'; import {MDCSelectIcon} from './icon'; import { FoundationMapType, - HelperTextFactory, - IconFactory, + HelperTextFactory, IconFactory, LabelFactory, LineRippleFactory, MenuFactory, OutlineFactory, SelectEventDetail, diff --git a/packages/mdc-select/package.json b/packages/mdc-select/package.json index 8b1aea69fae..dc04d97d69f 100644 --- a/packages/mdc-select/package.json +++ b/packages/mdc-select/package.json @@ -25,6 +25,7 @@ "@material/ripple": "^0.44.0", "@material/rtl": "^0.42.0", "@material/shape": "^0.43.0", + "@material/textfield": "^0.44.0", "@material/theme": "^0.43.0", "@material/typography": "^0.44.0" } diff --git a/packages/mdc-select/types.ts b/packages/mdc-select/types.ts index 977087c54f7..fc260d8764d 100644 --- a/packages/mdc-select/types.ts +++ b/packages/mdc-select/types.ts @@ -25,17 +25,15 @@ import {MDCFloatingLabel} from '@material/floating-label/index'; import {MDCLineRipple} from '@material/line-ripple/index'; import {MDCMenu} from '@material/menu/index'; import {MDCNotchedOutline} from '@material/notched-outline/index'; -import {MDCSelectHelperTextFoundation} from './helper-text/index'; -import {MDCSelectIconFoundation} from './icon/index'; +import {MDCSelectHelperText, MDCSelectHelperTextFoundation} from './helper-text/index'; +import {MDCSelectIcon, MDCSelectIconFoundation} from './icon/index'; export interface FoundationMapType { leadingIcon: MDCSelectIconFoundation; helperText: MDCSelectHelperTextFoundation; } -export interface SelectEvent extends Event { - detail: SelectEventDetail; -} +export type SelectEvent = CustomEvent; export interface SelectEventDetail { value: string; @@ -44,8 +42,8 @@ export interface SelectEventDetail { // TODO(acdvorak): Every component should export its own factory and event types. export type LineRippleFactory = (el: Element) => MDCLineRipple; -export type HelperTextFactory = (el: Element) => MDCTextFieldHelperText; +export type HelperTextFactory = (el: Element) => MDCSelectHelperText; export type MenuFactory = (el: Element) => MDCMenu; -export type IconFactory = (el: Element) => MDCTextFieldIcon; +export type IconFactory = (el: Element) => MDCSelectIcon; export type LabelFactory = (el: Element) => MDCFloatingLabel; export type OutlineFactory = (el: Element) => MDCNotchedOutline; From fb0b9961846adadd7c5512810f415fef957dca1a Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Sat, 9 Feb 2019 13:04:56 -0800 Subject: [PATCH 04/16] WIP: Make Istanbul happy --- packages/mdc-select/foundation.ts | 9 ++++----- packages/mdc-select/helper-text/foundation.ts | 2 +- packages/mdc-select/icon/foundation.ts | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/mdc-select/foundation.ts b/packages/mdc-select/foundation.ts index b3b5aa93051..a7f34a21d76 100644 --- a/packages/mdc-select/foundation.ts +++ b/packages/mdc-select/foundation.ts @@ -22,12 +22,11 @@ */ import {MDCFoundation} from '@material/base/foundation'; - import {MDCSelectAdapter} from './adapter'; import {cssClasses, numbers, strings} from './constants'; -import {FoundationMapType} from './types'; import {MDCSelectHelperTextFoundation} from './helper-text/index'; import {MDCSelectIconFoundation} from './icon/index'; +import {FoundationMapType} from './types'; class MDCSelectFoundation extends MDCFoundation { static get cssClasses() { @@ -77,10 +76,10 @@ class MDCSelectFoundation extends MDCFoundation { private readonly helperText_: MDCSelectHelperTextFoundation | undefined; /** - * @param {!MDCSelectAdapter} adapter - * @param {!FoundationMapType=} foundationMap Map from subcomponent names to their subfoundations. + * @param adapter + * @param foundationMap Map from subcomponent names to their subfoundations. */ - constructor(adapter: Partial = {}, foundationMap: Partial = {}) { + constructor(adapter?: Partial, foundationMap: Partial = {}) { super({...MDCSelectFoundation.defaultAdapter, ...adapter}); this.leadingIcon_ = foundationMap.leadingIcon; diff --git a/packages/mdc-select/helper-text/foundation.ts b/packages/mdc-select/helper-text/foundation.ts index 107ad7db1fa..bb7ee871642 100644 --- a/packages/mdc-select/helper-text/foundation.ts +++ b/packages/mdc-select/helper-text/foundation.ts @@ -50,7 +50,7 @@ class MDCSelectHelperTextFoundation extends MDCFoundation = {}) { + constructor(adapter?: Partial) { super({...MDCSelectHelperTextFoundation.defaultAdapter, ...adapter}); } diff --git a/packages/mdc-select/icon/foundation.ts b/packages/mdc-select/icon/foundation.ts index 39f0b937491..41e16107df2 100644 --- a/packages/mdc-select/icon/foundation.ts +++ b/packages/mdc-select/icon/foundation.ts @@ -53,7 +53,7 @@ class MDCSelectIconFoundation extends MDCFoundation { private savedTabIndex_!: string | null; // assigned in initialSyncWithDOM() private readonly interactionHandler_!: EventListener; // assigned in initialSyncWithDOM() - constructor(adapter: Partial = {}) { + constructor(adapter?: Partial) { super({...MDCSelectIconFoundation.defaultAdapter, ...adapter}); this.savedTabIndex_ = null; From 91f5975bfc68af5c21a30ad34653fee6a080683e Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Mon, 11 Feb 2019 13:40:20 -0800 Subject: [PATCH 05/16] WIP: Change `MDCList.listElements` from `HTMLElement[]` back to `Element[]` --- packages/mdc-list/index.ts | 17 ++++++++--------- packages/mdc-menu/index.ts | 6 +++--- packages/mdc-select/index.ts | 8 ++++---- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/mdc-list/index.ts b/packages/mdc-list/index.ts index b57bde92dc3..022734e4866 100644 --- a/packages/mdc-list/index.ts +++ b/packages/mdc-list/index.ts @@ -33,8 +33,8 @@ class MDCList extends MDCComponent { this.foundation_.setVerticalOrientation(value); } - get listElements(): HTMLElement[] { - return [].slice.call(this.root_.querySelectorAll(strings.ENABLED_ITEMS_SELECTOR)); + get listElements(): Element[] { + return [].slice.call(this.root_.querySelectorAll(strings.ENABLED_ITEMS_SELECTOR)); } set wrapFocus(value: boolean) { @@ -104,16 +104,16 @@ class MDCList extends MDCComponent { */ initializeListType() { const checkboxListItems = this.root_.querySelectorAll(strings.ARIA_ROLE_CHECKBOX_SELECTOR); - const singleSelectedListItem = this.root_.querySelector(` + 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); + const radioSelectedListItem = this.root_.querySelector(strings.ARIA_CHECKED_RADIO_SELECTOR); if (checkboxListItems.length) { const preselectedItems = this.root_.querySelectorAll(strings.ARIA_CHECKED_CHECKBOX_SELECTOR); this.selectedIndex = - [].map.call(preselectedItems, (listItem: HTMLElement) => this.listElements.indexOf(listItem)) as number[]; + [].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); @@ -135,12 +135,12 @@ class MDCList extends MDCComponent { } }, focusItemAtIndex: (index) => { - const element = this.listElements[index] as HTMLElement; + const element = this.listElements[index] as HTMLElement | null; if (element) { element.focus(); } }, - getFocusedElementIndex: () => this.listElements.indexOf(document.activeElement as HTMLElement), + getFocusedElementIndex: () => this.listElements.indexOf(document.activeElement!), getListItemCount: () => this.listElements.length, hasCheckboxAtIndex: (index) => { const listItem = this.listElements[index]; @@ -203,8 +203,7 @@ class MDCList extends MDCComponent { */ private getListItemIndex_(evt: Event) { const eventTarget = evt.target as Element; - const nearestParent = - ponyfill.closest(eventTarget, `.${cssClasses.LIST_ITEM_CLASS}, .${cssClasses.ROOT}`); + const nearestParent = ponyfill.closest(eventTarget, `.${cssClasses.LIST_ITEM_CLASS}, .${cssClasses.ROOT}`); // Get the index of the element if it is a list item. if (nearestParent && ponyfill.matches(nearestParent, `.${cssClasses.LIST_ITEM_CLASS}`)) { diff --git a/packages/mdc-menu/index.ts b/packages/mdc-menu/index.ts index 8d8881a28a1..c371037b248 100644 --- a/packages/mdc-menu/index.ts +++ b/packages/mdc-menu/index.ts @@ -108,7 +108,7 @@ class MDCMenu extends MDCComponent { * the items container that are proper list items, and not supplemental / presentational DOM * elements. */ - get items(): HTMLElement[] { + get items(): Element[] { return this.list_ ? this.list_.listElements : []; } @@ -191,10 +191,10 @@ class MDCMenu extends MDCComponent { }, elementContainsClass: (element, className) => element.classList.contains(className), closeSurface: () => this.open = false, - getElementIndex: (element) => this.items.indexOf(element as HTMLElement), + getElementIndex: (element) => this.items.indexOf(element), getParentElement: (element) => element.parentElement, getSelectedElementIndex: (selectionGroup) => { - const selectedListItem = selectionGroup.querySelector(`.${cssClasses.MENU_SELECTED_LIST_ITEM}`); + const selectedListItem = selectionGroup.querySelector(`.${cssClasses.MENU_SELECTED_LIST_ITEM}`); return selectedListItem ? this.items.indexOf(selectedListItem) : -1; }, notifySelected: (evtData) => this.emit(strings.SELECTED_EVENT, { diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index 0a591525f00..97e29c4a687 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -89,7 +89,7 @@ class MDCSelect extends MDCComponent implements RippleCapab get selectedIndex(): number { let selectedIndex = -1; if (this.menuElement_ && this.menu_) { - const selectedEl = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)!; + const selectedEl = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)!; selectedIndex = this.menu_.items.indexOf(selectedEl); } else if (this.nativeControl_) { selectedIndex = this.nativeControl_.selectedIndex; @@ -245,7 +245,8 @@ class MDCSelect extends MDCComponent implements RippleCapab this.handleMenuOpened_ = () => { // Menu should open to the last selected element. if (this.selectedIndex >= 0) { - this.menu_!.items[this.selectedIndex].focus(); + const selectedItemEl = this.menu_!.items[this.selectedIndex] as HTMLElement; + selectedItemEl.focus(); } }; this.handleMenuClosed_ = () => { @@ -413,8 +414,7 @@ class MDCSelect extends MDCComponent implements RippleCapab return ''; }, setValue: (value: string) => { - const element = - this.menuElement_!.querySelector(`[${strings.ENHANCED_VALUE_ATTR}="${value}"]`); + const element = this.menuElement_!.querySelector(`[${strings.ENHANCED_VALUE_ATTR}="${value}"]`); this.setEnhancedSelectedIndex_(element ? this.menu_!.items.indexOf(element) : -1); }, openMenu: () => { From b50a47a7cc4810b9e2885a1ab281bd02d650ab4c Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Mon, 11 Feb 2019 13:43:46 -0800 Subject: [PATCH 06/16] WIP: `extends CustomEvent` --- packages/mdc-menu/types.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/mdc-menu/types.ts b/packages/mdc-menu/types.ts index 403d460f4bb..e40d4421ae7 100644 --- a/packages/mdc-menu/types.ts +++ b/packages/mdc-menu/types.ts @@ -24,13 +24,8 @@ import {MDCList} from '@material/list/index'; import {MDCMenuSurface} from '@material/menu-surface/index'; -export interface MenuItemEvent extends Event { - detail: MenuItemEventDetail; -} - -export interface DefaultMenuItemEvent extends Event { - detail: DefaultMenuItemEventDetail; -} +export type MenuItemEvent = CustomEvent; +export type DefaultMenuItemEvent = CustomEvent; /** * Event properties used by the adapter and foundation. From 01edf0f06678a21834f76c1987e771ff619c7f49 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Mon, 11 Feb 2019 13:48:05 -0800 Subject: [PATCH 07/16] WIP: Remove Closure annotations --- packages/mdc-select/foundation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mdc-select/foundation.ts b/packages/mdc-select/foundation.ts index a7f34a21d76..a00df7ea2bc 100644 --- a/packages/mdc-select/foundation.ts +++ b/packages/mdc-select/foundation.ts @@ -47,17 +47,17 @@ class MDCSelectFoundation extends MDCFoundation { static get defaultAdapter(): MDCSelectAdapter { // tslint:disable:object-literal-sort-keys return { - addClass: (/* className: string */) => undefined, - removeClass: (/* className: string */) => undefined, - hasClass: (/* className: string */) => false, + addClass: () => undefined, + removeClass: () => undefined, + hasClass: () => false, activateBottomLine: () => undefined, deactivateBottomLine: () => undefined, setValue: () => undefined, getValue: () => '', - floatLabel: (/* value: boolean */) => undefined, + floatLabel: () => undefined, getLabelWidth: () => 0, hasOutline: () => false, - notchOutline: (/* labelWidth: number, */) => undefined, + notchOutline: () => undefined, closeOutline: () => undefined, openMenu: () => undefined, closeMenu: () => undefined, From a28666319db67ed01132fe9e6b3cd24ff17b9b20 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Mon, 11 Feb 2019 13:48:16 -0800 Subject: [PATCH 08/16] WIP: Initialize `savedTabIndex_` inline --- packages/mdc-select/icon/foundation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/mdc-select/icon/foundation.ts b/packages/mdc-select/icon/foundation.ts index 41e16107df2..e46ec03aac9 100644 --- a/packages/mdc-select/icon/foundation.ts +++ b/packages/mdc-select/icon/foundation.ts @@ -50,13 +50,12 @@ class MDCSelectIconFoundation extends MDCFoundation { // tslint:enable:object-literal-sort-keys } - private savedTabIndex_!: string | null; // assigned in initialSyncWithDOM() + private savedTabIndex_: string | null = null; private readonly interactionHandler_!: EventListener; // assigned in initialSyncWithDOM() constructor(adapter?: Partial) { super({...MDCSelectIconFoundation.defaultAdapter, ...adapter}); - this.savedTabIndex_ = null; this.interactionHandler_ = (evt) => this.handleInteraction(evt); } From 5facf076ec8d1c148fb88bc145b858e5c5953db7 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Mon, 11 Feb 2019 13:48:29 -0800 Subject: [PATCH 09/16] WIP: Remove unnecessary `@material/textfield` dependency from `package.json` --- packages/mdc-select/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/mdc-select/package.json b/packages/mdc-select/package.json index dc04d97d69f..8b1aea69fae 100644 --- a/packages/mdc-select/package.json +++ b/packages/mdc-select/package.json @@ -25,7 +25,6 @@ "@material/ripple": "^0.44.0", "@material/rtl": "^0.42.0", "@material/shape": "^0.43.0", - "@material/textfield": "^0.44.0", "@material/theme": "^0.43.0", "@material/typography": "^0.44.0" } From 4fa0e79d6ab84964f883135d736670c7c65b773b Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Mon, 11 Feb 2019 15:45:52 -0800 Subject: [PATCH 10/16] WIP: Update filename to `.ts` in `js-bundle-factory.js` --- scripts/webpack/js-bundle-factory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/webpack/js-bundle-factory.js b/scripts/webpack/js-bundle-factory.js index f91236916ed..84df649e10a 100644 --- a/scripts/webpack/js-bundle-factory.js +++ b/scripts/webpack/js-bundle-factory.js @@ -173,7 +173,7 @@ class JsBundleFactory { notchedOutline: getAbsolutePath('/packages/mdc-notched-outline/index.ts'), radio: getAbsolutePath('/packages/mdc-radio/index.ts'), ripple: getAbsolutePath('/packages/mdc-ripple/index.ts'), - select: getAbsolutePath('/packages/mdc-select/index.js'), + select: getAbsolutePath('/packages/mdc-select/index.ts'), selectionControl: getAbsolutePath('/packages/mdc-selection-control/index.ts'), slider: getAbsolutePath('/packages/mdc-slider/index.ts'), snackbar: getAbsolutePath('/packages/mdc-snackbar/index.ts'), From de38dd63c5c47f154b90632f4775b88bf1ee0b2a Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Tue, 12 Feb 2019 14:12:31 -0800 Subject: [PATCH 11/16] WIP: Revert argument rename in `MDCComponent.emit()` --- packages/mdc-base/component.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/mdc-base/component.ts b/packages/mdc-base/component.ts index e2be9dec142..80b0402fb1a 100644 --- a/packages/mdc-base/component.ts +++ b/packages/mdc-base/component.ts @@ -100,16 +100,18 @@ class MDCComponent { } /** - * Fires a cross-browser-compatible custom event from the component root of the given type, - * with the given data. + * Fires a cross-browser-compatible custom event from the component root of the given type, with the given data. */ - emit(evtType: string, detail: T, bubbles = false) { + emit(evtType: string, evtData: T, shouldBubble = false) { let evt: CustomEvent; if (typeof CustomEvent === 'function') { - evt = new CustomEvent(evtType, {bubbles, detail}); + evt = new CustomEvent(evtType, { + bubbles: shouldBubble, + detail: evtData, + }); } else { evt = document.createEvent('CustomEvent'); - evt.initCustomEvent(evtType, bubbles, false, detail); + evt.initCustomEvent(evtType, shouldBubble, false, evtData); } this.root_.dispatchEvent(evt); From 01b22be3f47117aae8fa7b2c81772dbd65403c11 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Tue, 12 Feb 2019 14:29:55 -0800 Subject: [PATCH 12/16] WIP: Address review comments --- packages/mdc-list/index.ts | 2 +- packages/mdc-select/foundation.ts | 4 +- packages/mdc-select/helper-text/index.ts | 2 +- packages/mdc-select/icon/README.md | 4 +- packages/mdc-select/icon/foundation.ts | 16 +- packages/mdc-select/icon/index.ts | 2 +- packages/mdc-select/index.ts | 208 +++++++++++------------ 7 files changed, 121 insertions(+), 117 deletions(-) diff --git a/packages/mdc-list/index.ts b/packages/mdc-list/index.ts index d15711be3be..5bcc61eeda5 100644 --- a/packages/mdc-list/index.ts +++ b/packages/mdc-list/index.ts @@ -135,7 +135,7 @@ class MDCList extends MDCComponent { } }, focusItemAtIndex: (index) => { - const element = this.listElements[index] as HTMLElement | null; + const element = this.listElements[index] as HTMLElement | undefined; if (element) { element.focus(); } diff --git a/packages/mdc-select/foundation.ts b/packages/mdc-select/foundation.ts index a00df7ea2bc..52949b8a2d0 100644 --- a/packages/mdc-select/foundation.ts +++ b/packages/mdc-select/foundation.ts @@ -24,8 +24,8 @@ import {MDCFoundation} from '@material/base/foundation'; import {MDCSelectAdapter} from './adapter'; import {cssClasses, numbers, strings} from './constants'; -import {MDCSelectHelperTextFoundation} from './helper-text/index'; -import {MDCSelectIconFoundation} from './icon/index'; +import {MDCSelectHelperTextFoundation} from './helper-text/foundation'; +import {MDCSelectIconFoundation} from './icon/foundation'; import {FoundationMapType} from './types'; class MDCSelectFoundation extends MDCFoundation { diff --git a/packages/mdc-select/helper-text/index.ts b/packages/mdc-select/helper-text/index.ts index 20e1eaf11cd..a9ee1f208f5 100644 --- a/packages/mdc-select/helper-text/index.ts +++ b/packages/mdc-select/helper-text/index.ts @@ -41,7 +41,7 @@ class MDCSelectHelperText extends MDCComponent { hasClass: (className) => this.root_.classList.contains(className), setAttr: (attr, value) => this.root_.setAttribute(attr, value), removeAttr: (attr) => this.root_.removeAttribute(attr), - setContent: (content) => this.root_.textContent = content, + setContent: (content) => { this.root_.textContent = content; }, }); // tslint:enable:object-literal-sort-keys } diff --git a/packages/mdc-select/icon/README.md b/packages/mdc-select/icon/README.md index aa032d6129c..39062c6deb7 100644 --- a/packages/mdc-select/icon/README.md +++ b/packages/mdc-select/icon/README.md @@ -114,8 +114,8 @@ Method Signature | Description `setAttr(attr: string, value: string) => void` | Sets an attribute with a given value on the icon element. `removeAttr(attr: string) => void` | Removes an attribute from the icon element. `setContent(content: string) => void` | Sets the text content of the icon element. -`registerInteractionHandler(evtType: EventType, handler: EventListener) => void` | Registers an event listener for a given event. -`deregisterInteractionHandler(evtType: EventType, handler: EventListener) => void` | Deregisters an event listener for a given event. +`registerInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event listener for a given event. +`deregisterInteractionHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener for a given event. `notifyIconAction() => void` | Emits a custom event "MDCSelect:icon" denoting a user has clicked the icon, which bubbles to the top-level select element. ### `MDCSelectIconFoundation` diff --git a/packages/mdc-select/icon/foundation.ts b/packages/mdc-select/icon/foundation.ts index e46ec03aac9..69a4613b238 100644 --- a/packages/mdc-select/icon/foundation.ts +++ b/packages/mdc-select/icon/foundation.ts @@ -22,11 +22,13 @@ */ import {MDCFoundation} from '@material/base/foundation'; -import {EventType} from '@material/base/index'; +import {SpecificEventListener} from '@material/base/types'; import {MDCSelectIconAdapter} from './adapter'; import {strings} from './constants'; -const CLICK_KEYDOWN_EVENTS: EventType[] = ['click', 'keydown']; +type InteractionEventType = 'click' | 'keydown'; + +const INTERACTION_EVENTS: InteractionEventType[] = ['click', 'keydown']; class MDCSelectIconFoundation extends MDCFoundation { static get strings() { @@ -51,7 +53,9 @@ class MDCSelectIconFoundation extends MDCFoundation { } private savedTabIndex_: string | null = null; - private readonly interactionHandler_!: EventListener; // assigned in initialSyncWithDOM() + + // assigned in initialSyncWithDOM() + private readonly interactionHandler_!: SpecificEventListener; constructor(adapter?: Partial) { super({...MDCSelectIconFoundation.defaultAdapter, ...adapter}); @@ -62,13 +66,13 @@ class MDCSelectIconFoundation extends MDCFoundation { init() { this.savedTabIndex_ = this.adapter_.getAttr('tabindex'); - CLICK_KEYDOWN_EVENTS.forEach((evtType) => { + INTERACTION_EVENTS.forEach((evtType) => { this.adapter_.registerInteractionHandler(evtType, this.interactionHandler_); }); } destroy() { - CLICK_KEYDOWN_EVENTS.forEach((evtType) => { + INTERACTION_EVENTS.forEach((evtType) => { this.adapter_.deregisterInteractionHandler(evtType, this.interactionHandler_); }); } @@ -95,7 +99,7 @@ class MDCSelectIconFoundation extends MDCFoundation { this.adapter_.setContent(content); } - handleInteraction(evt: Event) { + handleInteraction(evt: MouseEvent | KeyboardEvent) { const isEnterKey = (evt as KeyboardEvent).key === 'Enter' || (evt as KeyboardEvent).keyCode === 13; if (evt.type === 'click' || isEnterKey) { this.adapter_.notifyIconAction(); diff --git a/packages/mdc-select/icon/index.ts b/packages/mdc-select/icon/index.ts index f4fb5c65e9d..58c27f72220 100644 --- a/packages/mdc-select/icon/index.ts +++ b/packages/mdc-select/icon/index.ts @@ -39,7 +39,7 @@ class MDCSelectIcon extends MDCComponent { getAttr: (attr) => this.root_.getAttribute(attr), setAttr: (attr, value) => this.root_.setAttribute(attr, value), removeAttr: (attr) => this.root_.removeAttribute(attr), - setContent: (content) => this.root_.textContent = content, + setContent: (content) => { this.root_.textContent = content; }, registerInteractionHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), deregisterInteractionHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), notifyIconAction: () => this.emit( diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index 97e29c4a687..0c35506bc31 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -61,7 +61,7 @@ class MDCSelect extends MDCComponent implements RippleCapab private selectedText_!: HTMLElement | null; private hiddenInput_!: HTMLInputElement | null; - private leadingIcon_!: MDCSelectIcon | null; + private leadingIcon_?: MDCSelectIcon; private helperText_!: MDCSelectHelperText | null; private menuElement_!: Element | null; private ripple!: MDCRipple | null; @@ -78,103 +78,6 @@ class MDCSelect extends MDCComponent implements RippleCapab private handleMenuSelected_!: CustomEventListener; // assigned in initialize() private validationObserver_!: MutationObserver; // assigned in initialize() - get value(): string { - return this.foundation_.getValue(); - } - - set value(value: string) { - this.foundation_.setValue(value); - } - - get selectedIndex(): number { - let selectedIndex = -1; - if (this.menuElement_ && this.menu_) { - const selectedEl = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)!; - selectedIndex = this.menu_.items.indexOf(selectedEl); - } else if (this.nativeControl_) { - selectedIndex = this.nativeControl_.selectedIndex; - } - return selectedIndex; - } - - set selectedIndex(selectedIndex: number) { - this.foundation_.setSelectedIndex(selectedIndex); - } - - get disabled(): boolean { - return this.root_.classList.contains(cssClasses.DISABLED) || - (this.nativeControl_ ? this.nativeControl_.disabled : false); - } - - set disabled(disabled: boolean) { - this.foundation_.setDisabled(disabled); - } - - set leadingIconAriaLabel(label: string) { - this.foundation_.setLeadingIconAriaLabel(label); - } - - /** - * Sets the text content of the leading icon. - */ - set leadingIconContent(content: string) { - this.foundation_.setLeadingIconContent(content); - } - - /** - * Sets the text content of the helper text. - */ - set helperTextContent(content: string) { - this.foundation_.setHelperTextContent(content); - } - - /** - * Sets the current invalid state of the select. - */ - set valid(isValid: boolean) { - this.foundation_.setValid(isValid); - } - - /** - * Checks if the select is in a valid state. - */ - get valid(): boolean { - return this.foundation_.isValid(); - } - - /** - * Sets the control to the required state. - */ - set required(isRequired: boolean) { - if (this.nativeControl_) { - this.nativeControl_.required = isRequired; - } else { - if (isRequired) { - this.selectedText_!.setAttribute('aria-required', isRequired.toString()); - } else { - this.selectedText_!.removeAttribute('aria-required'); - } - } - } - - /** - * Returns whether the select is required. - */ - get required(): boolean { - if (this.nativeControl_) { - return this.nativeControl_.required; - } else { - return this.selectedText_!.getAttribute('aria-required') === 'true'; - } - } - - /** - * Recomputes the outline SVG path for the outline element. - */ - layout() { - this.foundation_.layout(); - } - initialize( labelFactory: LabelFactory = (el) => new MDCFloatingLabel(el), lineRippleFactory: LineRippleFactory = (el) => new MDCLineRipple(el), @@ -336,14 +239,111 @@ class MDCSelect extends MDCComponent implements RippleCapab super.destroy(); } + get value(): string { + return this.foundation_.getValue(); + } + + set value(value: string) { + this.foundation_.setValue(value); + } + + get selectedIndex(): number { + let selectedIndex = -1; + if (this.menuElement_ && this.menu_) { + const selectedEl = this.menuElement_.querySelector(strings.SELECTED_ITEM_SELECTOR)!; + selectedIndex = this.menu_.items.indexOf(selectedEl); + } else if (this.nativeControl_) { + selectedIndex = this.nativeControl_.selectedIndex; + } + return selectedIndex; + } + + set selectedIndex(selectedIndex: number) { + this.foundation_.setSelectedIndex(selectedIndex); + } + + get disabled(): boolean { + return this.root_.classList.contains(cssClasses.DISABLED) || + (this.nativeControl_ ? this.nativeControl_.disabled : false); + } + + set disabled(disabled: boolean) { + this.foundation_.setDisabled(disabled); + } + + set leadingIconAriaLabel(label: string) { + this.foundation_.setLeadingIconAriaLabel(label); + } + + /** + * Sets the text content of the leading icon. + */ + set leadingIconContent(content: string) { + this.foundation_.setLeadingIconContent(content); + } + + /** + * Sets the text content of the helper text. + */ + set helperTextContent(content: string) { + this.foundation_.setHelperTextContent(content); + } + + /** + * Sets the current invalid state of the select. + */ + set valid(isValid: boolean) { + this.foundation_.setValid(isValid); + } + + /** + * Checks if the select is in a valid state. + */ + get valid(): boolean { + return this.foundation_.isValid(); + } + + /** + * Sets the control to the required state. + */ + set required(isRequired: boolean) { + if (this.nativeControl_) { + this.nativeControl_.required = isRequired; + } else { + if (isRequired) { + this.selectedText_!.setAttribute('aria-required', isRequired.toString()); + } else { + this.selectedText_!.removeAttribute('aria-required'); + } + } + } + + /** + * Returns whether the select is required. + */ + get required(): boolean { + if (this.nativeControl_) { + return this.nativeControl_.required; + } else { + return this.selectedText_!.getAttribute('aria-required') === 'true'; + } + } + + /** + * Recomputes the outline SVG path for the outline element. + */ + layout() { + this.foundation_.layout(); + } + getDefaultFoundation(): MDCSelectFoundation { return new MDCSelectFoundation({ - ...(this.nativeControl_ ? this.getNativeSelectAdapterMethods_() : this.getEnhancedSelectAdapterMethods_()), - ...this.getCommonAdapterMethods_(), - ...this.getOutlineAdapterMethods_(), - ...this.getLabelAdapterMethods_(), - }, - this.getFoundationMap_(), + ...(this.nativeControl_ ? this.getNativeSelectAdapterMethods_() : this.getEnhancedSelectAdapterMethods_()), + ...this.getCommonAdapterMethods_(), + ...this.getOutlineAdapterMethods_(), + ...this.getLabelAdapterMethods_(), + }, + this.getFoundationMap_(), ); } From ebbbbb908181db0d95a3de4c4abc3740e36ec07b Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Tue, 12 Feb 2019 14:33:16 -0800 Subject: [PATCH 13/16] WIP: Re-indent --- packages/mdc-select/index.ts | 23 ++++++++++++----------- packages/mdc-select/types.ts | 1 - 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index 0c35506bc31..2e1dc6ab0d5 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -79,12 +79,13 @@ class MDCSelect extends MDCComponent implements RippleCapab private validationObserver_!: MutationObserver; // assigned in initialize() initialize( - labelFactory: LabelFactory = (el) => new MDCFloatingLabel(el), - lineRippleFactory: LineRippleFactory = (el) => new MDCLineRipple(el), - outlineFactory: OutlineFactory = (el) => new MDCNotchedOutline(el), - menuFactory: MenuFactory = (el) => new MDCMenu(el), - iconFactory: IconFactory = (el) => new MDCSelectIcon(el), - helperTextFactory: HelperTextFactory = (el) => new MDCSelectHelperText(el)) { + labelFactory: LabelFactory = (el) => new MDCFloatingLabel(el), + lineRippleFactory: LineRippleFactory = (el) => new MDCLineRipple(el), + outlineFactory: OutlineFactory = (el) => new MDCNotchedOutline(el), + menuFactory: MenuFactory = (el) => new MDCMenu(el), + iconFactory: IconFactory = (el) => new MDCSelectIcon(el), + helperTextFactory: HelperTextFactory = (el) => new MDCSelectHelperText(el), + ) { this.nativeControl_ = this.root_.querySelector(strings.NATIVE_CONTROL_SELECTOR); this.selectedText_ = this.root_.querySelector(strings.SELECTED_TEXT_SELECTOR); @@ -385,12 +386,12 @@ class MDCSelect extends MDCComponent implements RippleCapab // tslint:disable:object-literal-sort-keys return { getValue: () => this.nativeControl_!.value, - setValue: (value: string) => this.nativeControl_!.value = value, + setValue: (value: string) => { this.nativeControl_!.value = value; }, openMenu: () => undefined, closeMenu: () => undefined, isMenuOpen: () => false, - setSelectedIndex: (index: number) => this.nativeControl_!.selectedIndex = index, - setDisabled: (isDisabled: boolean) => this.nativeControl_!.disabled = isDisabled, + setSelectedIndex: (index: number) => { this.nativeControl_!.selectedIndex = index; }, + setDisabled: (isDisabled: boolean) => { this.nativeControl_!.disabled = isDisabled; }, setValid: (isValid: boolean) => { if (isValid) { this.root_.classList.remove(cssClasses.INVALID); @@ -578,8 +579,8 @@ class MDCSelect extends MDCComponent implements RippleCapab const getAttributesList = (mutationsList: MutationRecord[]): string[] => { return mutationsList - .map((mutation) => mutation.attributeName) - .filter((attributeName) => attributeName) as string[]; + .map((mutation) => mutation.attributeName) + .filter((attributeName) => attributeName) as string[]; }; const observer = new MutationObserver((mutationsList) => observerHandler(getAttributesList(mutationsList))); const element = this.nativeControl_ || this.selectedText_!; diff --git a/packages/mdc-select/types.ts b/packages/mdc-select/types.ts index fc260d8764d..0d031c687a2 100644 --- a/packages/mdc-select/types.ts +++ b/packages/mdc-select/types.ts @@ -40,7 +40,6 @@ export interface SelectEventDetail { index: number; } -// TODO(acdvorak): Every component should export its own factory and event types. export type LineRippleFactory = (el: Element) => MDCLineRipple; export type HelperTextFactory = (el: Element) => MDCSelectHelperText; export type MenuFactory = (el: Element) => MDCMenu; From a236050fc9693a1a6027a1a2b7f5ce5cd06ea754 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Tue, 12 Feb 2019 14:44:53 -0800 Subject: [PATCH 14/16] WIP: Address review comments --- packages/mdc-select/index.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index 2e1dc6ab0d5..1505eabb372 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -42,8 +42,10 @@ import { OutlineFactory, SelectEventDetail, } from './types'; +type PointerEventType = 'mousedown' | 'touchstart'; + +const POINTER_EVENTS: PointerEventType[] = ['mousedown', 'touchstart']; const VALIDATION_ATTR_WHITELIST = ['required', 'aria-required']; -const MOUSEDOWN_TOUCHSTART_EVENTS: EventType[] = ['mousedown', 'touchstart']; class MDCSelect extends MDCComponent implements RippleCapableSurface { static attachTo(root: Element): MDCSelect { @@ -71,7 +73,7 @@ class MDCSelect extends MDCComponent implements RippleCapab private handleChange_!: SpecificEventListener<'change'>; // assigned in initialize() private handleFocus_!: SpecificEventListener<'focus'>; // assigned in initialize() private handleBlur_!: SpecificEventListener<'blur'>; // assigned in initialize() - private handleClick_!: SpecificEventListener<'click'>; // assigned in initialize() + private handleClick_!: SpecificEventListener; // assigned in initialize() private handleKeydown_!: SpecificEventListener<'keydown'>; // assigned in initialize() private handleMenuOpened_!: EventListener; // assigned in initialize() private handleMenuClosed_!: EventListener; // assigned in initialize() @@ -171,7 +173,7 @@ class MDCSelect extends MDCComponent implements RippleCapab element.addEventListener('focus', this.handleFocus_); element.addEventListener('blur', this.handleBlur_); - MOUSEDOWN_TOUCHSTART_EVENTS.forEach((evtType) => { + POINTER_EVENTS.forEach((evtType) => { element.addEventListener(evtType, this.handleClick_ as EventListener); }); @@ -210,7 +212,7 @@ class MDCSelect extends MDCComponent implements RippleCapab element.removeEventListener('focus', this.handleFocus_); element.removeEventListener('blur', this.handleBlur_); element.removeEventListener('keydown', this.handleKeydown_); - MOUSEDOWN_TOUCHSTART_EVENTS.forEach((evtType) => { + POINTER_EVENTS.forEach((evtType) => { element.removeEventListener(evtType, this.handleClick_ as EventListener); }); @@ -498,9 +500,9 @@ class MDCSelect extends MDCComponent implements RippleCapab /** * Calculates where the line ripple should start based on the x coordinate within the component. */ - private getNormalizedXCoordinate_(evt: MouseEvent): number { + private getNormalizedXCoordinate_(evt: MouseEvent | TouchEvent): number { const targetClientRect = (evt.target as Element).getBoundingClientRect(); - const xCoordinate = evt.clientX; // TODO(acdvorak): How should this be typed? + const xCoordinate = (evt as MouseEvent).clientX; // TODO(acdvorak): How does this work for TouchEvent? return xCoordinate - targetClientRect.left; } From 1e8b7e27e6c7de999018f6f287706d9de7325680 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Tue, 12 Feb 2019 15:23:43 -0800 Subject: [PATCH 15/16] WIP: Throw an error if required subelements are missing --- packages/mdc-select/foundation.ts | 1 + packages/mdc-select/index.ts | 70 ++++++++++++------------- test/unit/mdc-select/mdc-select.test.js | 4 ++ 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/packages/mdc-select/foundation.ts b/packages/mdc-select/foundation.ts index 52949b8a2d0..0722968ddcb 100644 --- a/packages/mdc-select/foundation.ts +++ b/packages/mdc-select/foundation.ts @@ -75,6 +75,7 @@ class MDCSelectFoundation extends MDCFoundation { private readonly leadingIcon_: MDCSelectIconFoundation | undefined; private readonly helperText_: MDCSelectHelperTextFoundation | undefined; + /* istanbul ignore next: optional argument is not a branch statement */ /** * @param adapter * @param foundationMap Map from subcomponent names to their subfoundations. diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index 1505eabb372..823fcbcc167 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -59,8 +59,10 @@ class MDCSelect extends MDCComponent implements RippleCapab private menuOpened_ = false; // Exactly one of these fields must be non-null. - private nativeControl_!: HTMLSelectElement | null; - private selectedText_!: HTMLElement | null; + private nativeControl_!: HTMLSelectElement | null; // assigned in initialize() + private selectedText_!: HTMLElement | null; // assigned in initialize() + + private targetElement_!: HTMLElement; // assigned in initialize() private hiddenInput_!: HTMLInputElement | null; private leadingIcon_?: MDCSelectIcon; @@ -91,6 +93,22 @@ class MDCSelect extends MDCComponent implements RippleCapab this.nativeControl_ = this.root_.querySelector(strings.NATIVE_CONTROL_SELECTOR); this.selectedText_ = this.root_.querySelector(strings.SELECTED_TEXT_SELECTOR); + const targetElement = this.nativeControl_ || this.selectedText_; + if (!targetElement) { + throw new Error( + 'MDCSelect: Missing required element: Exactly one of the following selectors must be present: ' + + `'${strings.NATIVE_CONTROL_SELECTOR}' or '${strings.SELECTED_TEXT_SELECTOR}'`, + ); + } + + this.targetElement_ = targetElement; + if (this.targetElement_.hasAttribute(strings.ARIA_CONTROLS)) { + const helperTextElement = document.getElementById(this.targetElement_.getAttribute(strings.ARIA_CONTROLS)!); + if (helperTextElement) { + this.helperText_ = helperTextFactory(helperTextElement); + } + } + if (this.selectedText_) { this.enhancedSelectSetup_(menuFactory); } @@ -114,15 +132,6 @@ class MDCSelect extends MDCComponent implements RippleCapab } } - // At least one of these elements must be non-null. - const element = this.nativeControl_ || this.selectedText_!; - if (element.hasAttribute(strings.ARIA_CONTROLS)) { - const helperTextElement = document.getElementById(element.getAttribute(strings.ARIA_CONTROLS)!); - if (helperTextElement) { - this.helperText_ = helperTextFactory(helperTextElement); - } - } - if (!this.root_.classList.contains(cssClasses.OUTLINED)) { this.ripple = this.initRipple_(); } @@ -166,15 +175,12 @@ class MDCSelect extends MDCComponent implements RippleCapab } }; - // One of these elements must be non-null. - const element = this.nativeControl_ || this.selectedText_!; - - element.addEventListener('change', this.handleChange_); - element.addEventListener('focus', this.handleFocus_); - element.addEventListener('blur', this.handleBlur_); + this.targetElement_.addEventListener('change', this.handleChange_); + this.targetElement_.addEventListener('focus', this.handleFocus_); + this.targetElement_.addEventListener('blur', this.handleBlur_); POINTER_EVENTS.forEach((evtType) => { - element.addEventListener(evtType, this.handleClick_ as EventListener); + this.targetElement_.addEventListener(evtType, this.handleClick_ as EventListener); }); if (this.menuElement_) { @@ -205,15 +211,12 @@ class MDCSelect extends MDCComponent implements RippleCapab } destroy() { - // One of these elements must be non-null. - const element = this.nativeControl_ || this.selectedText_!; - - element.removeEventListener('change', this.handleChange_); - element.removeEventListener('focus', this.handleFocus_); - element.removeEventListener('blur', this.handleBlur_); - element.removeEventListener('keydown', this.handleKeydown_); + this.targetElement_.removeEventListener('change', this.handleChange_); + this.targetElement_.removeEventListener('focus', this.handleFocus_); + this.targetElement_.removeEventListener('blur', this.handleBlur_); + this.targetElement_.removeEventListener('keydown', this.handleKeydown_); POINTER_EVENTS.forEach((evtType) => { - element.removeEventListener(evtType, this.handleClick_ as EventListener); + this.targetElement_.removeEventListener(evtType, this.handleClick_ as EventListener); }); if (this.menu_) { @@ -366,17 +369,14 @@ class MDCSelect extends MDCComponent implements RippleCapab } private initRipple_(): MDCRipple { - // At least one of the elements must be non-null. - const element = this.nativeControl_ || this.selectedText_!; - // tslint:disable:object-literal-sort-keys const foundation = new MDCRippleFoundation({ ...MDCRipple.createAdapter(this), registerInteractionHandler: (evtType: E, handler: SpecificEventListener) => { - element.addEventListener(evtType, handler); + this.targetElement_.addEventListener(evtType, handler); }, deregisterInteractionHandler: (evtType: E, handler: SpecificEventListener) => { - element.removeEventListener(evtType, handler); + this.targetElement_.removeEventListener(evtType, handler); }, }); // tslint:enable:object-literal-sort-keys @@ -541,8 +541,9 @@ class MDCSelect extends MDCComponent implements RippleCapab } private initialSyncRequiredState_() { - const element = this.nativeControl_ || this.selectedText_!; - const isRequired = (element as HTMLSelectElement).required || element.getAttribute('aria-required') === 'true' + const isRequired = + (this.targetElement_ as HTMLSelectElement).required + || this.targetElement_.getAttribute('aria-required') === 'true' || this.root_.classList.contains(cssClasses.REQUIRED); if (isRequired) { if (this.nativeControl_) { @@ -585,8 +586,7 @@ class MDCSelect extends MDCComponent implements RippleCapab .filter((attributeName) => attributeName) as string[]; }; const observer = new MutationObserver((mutationsList) => observerHandler(getAttributesList(mutationsList))); - const element = this.nativeControl_ || this.selectedText_!; - observer.observe(element, {attributes: true}); + observer.observe(this.targetElement_, {attributes: true}); this.validationObserver_ = observer; } } diff --git a/test/unit/mdc-select/mdc-select.test.js b/test/unit/mdc-select/mdc-select.test.js index 9c0b06fceef..a32f7c30caa 100644 --- a/test/unit/mdc-select/mdc-select.test.js +++ b/test/unit/mdc-select/mdc-select.test.js @@ -130,6 +130,10 @@ test('attachTo returns a component instance', () => { assert.isOk(MDCSelect.attachTo(getFixture()) instanceof MDCSelect); }); +test('constructor throws an error when required elements are missing', () => { + assert.throws(() => new MDCSelect(bel`
`), 'Missing required element'); +}); + function setupTest(hasOutline = false, hasLabel = true, hasHelperText = false) { const bottomLine = new FakeBottomLine(); const label = new FakeLabel(); From a0cce8c9eeb13a10864b44103518018cb7f9b252 Mon Sep 17 00:00:00 2001 From: Andy Dvorak Date: Tue, 12 Feb 2019 15:32:55 -0800 Subject: [PATCH 16/16] WIP: Fix `TouchEvent` `clientX` bug --- packages/mdc-select/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/mdc-select/index.ts b/packages/mdc-select/index.ts index 823fcbcc167..a69222aab70 100644 --- a/packages/mdc-select/index.ts +++ b/packages/mdc-select/index.ts @@ -502,10 +502,14 @@ class MDCSelect extends MDCComponent implements RippleCapab */ private getNormalizedXCoordinate_(evt: MouseEvent | TouchEvent): number { const targetClientRect = (evt.target as Element).getBoundingClientRect(); - const xCoordinate = (evt as MouseEvent).clientX; // TODO(acdvorak): How does this work for TouchEvent? + const xCoordinate = this.isTouchEvent_(evt) ? evt.touches[0].clientX : evt.clientX; return xCoordinate - targetClientRect.left; } + private isTouchEvent_(evt: MouseEvent | TouchEvent): evt is TouchEvent { + return Boolean((evt as TouchEvent).touches); + } + /** * Returns a map of all subcomponents to subfoundations. */