diff --git a/packages/mdc-base/component.ts b/packages/mdc-base/component.ts index d39a498f570..80b0402fb1a 100644 --- a/packages/mdc-base/component.ts +++ b/packages/mdc-base/component.ts @@ -100,8 +100,7 @@ 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, evtData: T, shouldBubble = false) { let evt: CustomEvent; diff --git a/packages/mdc-list/index.ts b/packages/mdc-list/index.ts index cf2379589fd..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; + const element = this.listElements[index] as HTMLElement | undefined; if (element) { element.focus(); } diff --git a/packages/mdc-menu/types.ts b/packages/mdc-menu/types.ts index 5a96a05b009..e40d4421ae7 100644 --- a/packages/mdc-menu/types.ts +++ b/packages/mdc-menu/types.ts @@ -24,6 +24,9 @@ import {MDCList} from '@material/list/index'; import {MDCMenuSurface} from '@material/menu-surface/index'; +export type MenuItemEvent = CustomEvent; +export type DefaultMenuItemEvent = CustomEvent; + /** * 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 65% rename from packages/mdc-select/foundation.js rename to packages/mdc-select/foundation.ts index d295783a7b0..0722968ddcb 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 {MDCSelectHelperTextFoundation} from './helper-text/index'; -/* eslint-enable no-unused-vars */ -import {cssClasses, strings, numbers} from './constants'; +import {MDCSelectAdapter} from './adapter'; +import {cssClasses, numbers, strings} from './constants'; +import {MDCSelectHelperTextFoundation} from './helper-text/foundation'; +import {MDCSelectIconFoundation} from './icon/foundation'; +import {FoundationMapType} from './types'; -/** - * @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 */) => {}, - hasClass: (/* className: string */) => false, - activateBottomLine: () => {}, - deactivateBottomLine: () => {}, - setValue: () => {}, - getValue: () => {}, - floatLabel: (/* value: boolean */) => {}, - getLabelWidth: () => {}, + static get defaultAdapter(): MDCSelectAdapter { + // tslint:disable:object-literal-sort-keys + return { + addClass: () => undefined, + removeClass: () => undefined, + hasClass: () => false, + activateBottomLine: () => undefined, + deactivateBottomLine: () => undefined, + setValue: () => undefined, + getValue: () => '', + floatLabel: () => undefined, + getLabelWidth: () => 0, hasOutline: () => false, - notchOutline: (/* labelWidth: number, */) => {}, - closeOutline: () => {}, - openMenu: () => {}, - closeMenu: () => {}, - isMenuOpen: () => {}, - setSelectedIndex: () => {}, - setDisabled: () => {}, - setRippleCenter: () => {}, - notifyChange: () => {}, - checkValidity: () => {}, - setValid: () => {}, - }); + notchOutline: () => 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; + + /* istanbul ignore next: optional argument is not a branch statement */ /** - * @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, 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..bb7ee871642 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..a9ee1f208f5 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/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 56% rename from packages/mdc-select/icon/foundation.js rename to packages/mdc-select/icon/foundation.ts index 3524488224c..69a4613b238 100644 --- a/packages/mdc-select/icon/foundation.js +++ b/packages/mdc-select/icon/foundation.ts @@ -22,66 +22,62 @@ */ import {MDCFoundation} from '@material/base/foundation'; -import MDCSelectIconAdapter from './adapter'; +import {SpecificEventListener} from '@material/base/types'; +import {MDCSelectIconAdapter} from './adapter'; import {strings} from './constants'; +type InteractionEventType = 'click' | 'keydown'; -/** - * @extends {MDCFoundation} - * @final - */ -class MDCSelectIconFoundation extends MDCFoundation { - /** @return enum {string} */ +const INTERACTION_EVENTS: InteractionEventType[] = ['click', 'keydown']; + +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 = null; + + // assigned in initialSyncWithDOM() + private readonly interactionHandler_!: SpecificEventListener; - /** @private {string?} */ - this.savedTabIndex_ = null; + constructor(adapter?: Partial) { + super({...MDCSelectIconFoundation.defaultAdapter, ...adapter}); - /** @private {function(!Event): undefined} */ this.interactionHandler_ = (evt) => this.handleInteraction(evt); } init() { this.savedTabIndex_ = this.adapter_.getAttr('tabindex'); - ['click', 'keydown'].forEach((evtType) => { + INTERACTION_EVENTS.forEach((evtType) => { this.adapter_.registerInteractionHandler(evtType, this.interactionHandler_); }); } destroy() { - ['click', 'keydown'].forEach((evtType) => { + INTERACTION_EVENTS.forEach((evtType) => { this.adapter_.deregisterInteractionHandler(evtType, this.interactionHandler_); }); } - /** @param {boolean} disabled */ - setDisabled(disabled) { + setDisabled(disabled: boolean) { if (!this.savedTabIndex_) { return; } @@ -95,25 +91,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: MouseEvent | KeyboardEvent) { + 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..58c27f72220 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..a69222aab70 --- /dev/null +++ b/packages/mdc-select/index.ts @@ -0,0 +1,603 @@ +/** + * @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'; + +type PointerEventType = 'mousedown' | 'touchstart'; + +const POINTER_EVENTS: PointerEventType[] = ['mousedown', 'touchstart']; +const VALIDATION_ATTR_WHITELIST = ['required', 'aria-required']; + +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; // assigned in initialize() + private selectedText_!: HTMLElement | null; // assigned in initialize() + + private targetElement_!: HTMLElement; // assigned in initialize() + + private hiddenInput_!: HTMLInputElement | null; + private leadingIcon_?: MDCSelectIcon; + 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; // 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() + + 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); + + 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); + } + + 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); + } + } + + 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) { + const selectedItemEl = this.menu_!.items[this.selectedIndex] as HTMLElement; + selectedItemEl.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(); + } + }; + + this.targetElement_.addEventListener('change', this.handleChange_); + this.targetElement_.addEventListener('focus', this.handleFocus_); + this.targetElement_.addEventListener('blur', this.handleBlur_); + + POINTER_EVENTS.forEach((evtType) => { + this.targetElement_.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() { + 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) => { + this.targetElement_.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(); + } + + 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_(), + ); + } + + /** + * 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 { + // tslint:disable:object-literal-sort-keys + const foundation = new MDCRippleFoundation({ + ...MDCRipple.createAdapter(this), + registerInteractionHandler: (evtType: E, handler: SpecificEventListener) => { + this.targetElement_.addEventListener(evtType, handler); + }, + deregisterInteractionHandler: (evtType: E, handler: SpecificEventListener) => { + this.targetElement_.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 | TouchEvent): number { + const targetClientRect = (evt.target as Element).getBoundingClientRect(); + 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. + */ + 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 isRequired = + (this.targetElement_ as HTMLSelectElement).required + || this.targetElement_.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))); + observer.observe(this.targetElement_, {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..0d031c687a2 --- /dev/null +++ b/packages/mdc-select/types.ts @@ -0,0 +1,48 @@ +/** + * @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 {MDCSelectHelperText, MDCSelectHelperTextFoundation} from './helper-text/index'; +import {MDCSelectIcon, MDCSelectIconFoundation} from './icon/index'; + +export interface FoundationMapType { + leadingIcon: MDCSelectIconFoundation; + helperText: MDCSelectHelperTextFoundation; +} + +export type SelectEvent = CustomEvent; + +export interface SelectEventDetail { + value: string; + index: number; +} + +export type LineRippleFactory = (el: Element) => MDCLineRipple; +export type HelperTextFactory = (el: Element) => MDCSelectHelperText; +export type MenuFactory = (el: Element) => MDCMenu; +export type IconFactory = (el: Element) => MDCSelectIcon; +export type LabelFactory = (el: Element) => MDCFloatingLabel; +export type OutlineFactory = (el: Element) => MDCNotchedOutline; diff --git a/scripts/webpack/js-bundle-factory.js b/scripts/webpack/js-bundle-factory.js index c47c8c5260c..412ace43456 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'), 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();