/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import '../../menu/menu.js'; import {html, isServer, LitElement, nothing, PropertyValues} from 'lit'; import {property, query, queryAssignedElements, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {html as staticHtml, StaticValue} from 'lit/static-html.js'; import {Field} from '../../field/internal/field.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js'; import {redispatchEvent} from '../../internal/controller/events.js'; import {getActiveItem} from '../../list/internal/list-navigation-helpers.js'; import { CloseMenuEvent, isElementInSubtree, isSelectableKey, } from '../../menu/internal/controllers/shared.js'; import {TYPEAHEAD_RECORD} from '../../menu/internal/controllers/typeaheadController.js'; import {DEFAULT_TYPEAHEAD_BUFFER_TIME, Menu} from '../../menu/internal/menu.js'; import { createRequestDeselectionEvent, createRequestSelectionEvent, SelectOption, } from './selectoption/selectOptionController.js'; import {getSelectedItems, SelectOptionRecord} from './shared.js'; const VALUE = Symbol('value'); /** * @fires input Fired when a selection is made by the user via mouse or keyboard * interaction. * @fires change Fired when a selection is made by the user via mouse or * keyboard interaction. * @fires opening Fired when the select's menu is about to open. * @fires opened Fired when the select's menu has finished animations and * opened. * @fires closing Fired when the select's menu is about to close. * @fires closed Fired when the select's menu has finished animations and * closed. */ export abstract class Select extends LitElement { static { requestUpdateOnAriaChange(Select); } /** @nocollapse */ static readonly formAssociated = true; /** * Opens the menu synchronously with no animation. */ @property({type: Boolean}) quick = false; /** * Whether or not the select is required. */ @property({type: Boolean}) required = false; /** * Disables the select. */ @property({type: Boolean, reflect: true}) disabled = false; /** * The error message that replaces supporting text when `error` is true. If * `errorText` is an empty string, then the supporting text will continue to * show. * * This error message overrides the error message displayed by * `reportValidity()`. */ @property({type: String, attribute: 'error-text'}) errorText = ''; /** * The floating label for the field. */ @property() label = ''; /** * Conveys additional information below the select, such as how it should * be used. */ @property({type: String, attribute: 'supporting-text'}) supportingText = ''; /** * Gets or sets whether or not the select is in a visually invalid state. * * This error state overrides the error state controlled by * `reportValidity()`. */ @property({type: Boolean, reflect: true}) error = false; /** * Whether or not the underlying md-menu should be position: fixed to display * in a top-level manner, or position: absolute. * * position:fixed is useful for cases where select is inside of another * element with stacking context and hidden overflows such as `md-dialog`. */ @property({attribute: 'menu-positioning'}) menuPositioning: 'absolute' | 'fixed' = 'absolute'; /** * The max time between the keystrokes of the typeahead select / menu behavior * before it clears the typeahead buffer. */ @property({type: Number, attribute: 'typeahead-delay'}) typeaheadDelay = DEFAULT_TYPEAHEAD_BUFFER_TIME; /** * Whether or not the text field has a leading icon. Used for SSR. */ @property({type: Boolean, attribute: 'has-leading-icon'}) hasLeadingIcon = false; /** * Text to display in the field. Only set for SSR. */ @property({attribute: 'display-text'}) displayText = ''; /** * The value of the currently selected option. * * Note: For SSR, set `[selected]` on the requested option and `displayText` * rather than setting `value` setting `value` will incur a DOM query. */ @property() get value(): string { return this[VALUE]; } set value(value: string) { if (isServer) return; this.lastUserSetValue = value; this.select(value); } [VALUE] = ''; get options() { // NOTE: this does a DOM query. return (this.menu?.items ?? []) as SelectOption[]; } /** * The index of the currently selected option. * * Note: For SSR, set `[selected]` on the requested option and `displayText` * rather than setting `selectedIndex` setting `selectedIndex` will incur a * DOM query. */ @property({type: Number, attribute: 'selected-index'}) get selectedIndex(): number { // tslint:disable-next-line:enforce-name-casing const [_option, index] = (this.getSelectedOptions() ?? [])[0] ?? []; return index ?? -1; } set selectedIndex(index: number) { this.lastUserSetSelectedIndex = index; this.selectIndex(index); } /** * Returns an array of selected options. * * NOTE: md-select only suppoprts single selection. */ get selectedOptions() { return (this.getSelectedOptions() ?? []).map(([option]) => option); } /** * The HTML name to use in form submission. */ get name() { return this.getAttribute('name') ?? ''; } set name(name: string) { this.setAttribute('name', name); } /** * The associated form element with which this element's value will submit. */ get form() { return this.internals.form; } /** * The labels this element is associated with. */ get labels() { return this.internals.labels; } /** * Returns a ValidityState object that represents the validity states of the * checkbox. * * Note that selects will only set `valueMissing` if unselected and * `required`. */ get validity() { this.syncValidity(); return this.internals.validity; } /** * Returns the native validation error message. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process */ get validationMessage() { this.syncValidity(); return this.internals.validationMessage; } /** * Returns whether an element will successfully validate based on forms * validation rules and constraints. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process */ get willValidate() { this.syncValidity(); return this.internals.willValidate; } protected abstract readonly fieldTag: StaticValue; /** * Used for initializing select when the user sets the `value` directly. */ private lastUserSetValue: string | null = null; /** * Used for initializing select when the user sets the `selectedIndex` * directly. */ private lastUserSetSelectedIndex: number | null = null; /** * Used for `input` and `change` event change detection. */ private lastSelectedOption: SelectOption | null = null; // tslint:disable-next-line:enforce-name-casing private lastSelectedOptionRecords: SelectOptionRecord[] = []; /** * Whether or not a native error has been reported via `reportValidity()`. */ @state() private nativeError = false; /** * The validation message displayed from a native error via * `reportValidity()`. */ @state() private nativeErrorText = ''; private get hasError() { return this.error || this.nativeError; } @state() private focused = false; @state() private open = false; @query('.field') private readonly field!: Field | null; @query('md-menu') private readonly menu!: Menu | null; @query('#label') private readonly labelEl!: HTMLElement; @queryAssignedElements({slot: 'leading-icon', flatten: true}) private readonly leadingIcons!: Element[]; private customValidationMessage = ''; // Cast needed for closure private readonly internals = (this as HTMLElement).attachInternals(); /** * Selects an option given the value of the option, and updates MdSelect's * value. */ select(value: string) { const optionToSelect = this.options.find( (option) => option.value === value, ); if (optionToSelect) { this.selectItem(optionToSelect); } } /** * Selects an option given the index of the option, and updates MdSelect's * value. */ selectIndex(index: number) { const optionToSelect = this.options[index]; if (optionToSelect) { this.selectItem(optionToSelect); } } /** * Reset the select to its default value. */ reset() { for (const option of this.options) { option.selected = option.hasAttribute('selected'); } this.updateValueAndDisplayText(); this.nativeError = false; this.nativeErrorText = ''; } /** * Checks the select's native validation and returns whether or not the * element is valid. * * If invalid, this method will dispatch the `invalid` event. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement/checkValidity * * @return true if the select is valid, or false if not. */ checkValidity() { this.syncValidity(); return this.internals.checkValidity(); } /** * Checks the select's native validation and returns whether or not the * element is valid. * * If invalid, this method will dispatch the `invalid` event. * * This method will display or clear an error text message equal to the * select's `validationMessage`, unless the invalid event is canceled. * * Use `setCustomValidity()` to customize the `validationMessage`. * * This method can also be used to re-announce error messages to screen * readers. * * @return true if the select is valid, or false if not. */ reportValidity() { let invalidEvent: Event | undefined; this.addEventListener( 'invalid', (event) => { invalidEvent = event; }, {once: true}, ); const valid = this.checkValidity(); if (invalidEvent?.defaultPrevented) { return valid; } const prevMessage = this.getErrorText(); this.nativeError = !valid; this.nativeErrorText = this.validationMessage; if (prevMessage === this.getErrorText()) { this.field?.reannounceError(); } return valid; } /** * Sets the select's native validation error message. This is used to * customize `validationMessage`. * * When the error is not an empty string, the select is considered invalid * and `validity.customError` will be true. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement/setCustomValidity * * @param error The error message to display. */ setCustomValidity(error: string) { this.customValidationMessage = error; this.syncValidity(); } protected override update(changed: PropertyValues) { if (changed.has('required')) { this.syncValidity(); } } protected override async firstUpdated(changed: PropertyValues` validation message for i18n. private getRequiredValidationMessage() { const select = document.createElement('select'); select.required = true; return select.validationMessage; } /** @private */ formResetCallback() { this.reset(); } /** @private */ formStateRestoreCallback(state: string) { this.value = state; } }