UNPKG

@material/web

Version:
751 lines 26.7 kB
/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ var _a; import { __decorate } from "tslib"; import '../../menu/menu.js'; import { html, isServer, LitElement, nothing } from 'lit'; import { property, query, queryAssignedElements, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { html as staticHtml } from 'lit/static-html.js'; import { mixinDelegatesAria } from '../../internal/aria/delegate.js'; import { redispatchEvent } from '../../internal/events/redispatch-event.js'; import { createValidator, getValidityAnchor, mixinConstraintValidation, } from '../../labs/behaviors/constraint-validation.js'; import { mixinElementInternals } from '../../labs/behaviors/element-internals.js'; import { getFormValue, mixinFormAssociated, } from '../../labs/behaviors/form-associated.js'; import { mixinOnReportValidity, onReportValidity, } from '../../labs/behaviors/on-report-validity.js'; import { SelectValidator } from '../../labs/behaviors/validators/select-validator.js'; import { getActiveItem } from '../../list/internal/list-navigation-helpers.js'; import { FocusState, isElementInSubtree, isSelectableKey, } from '../../menu/internal/controllers/shared.js'; import { TYPEAHEAD_RECORD } from '../../menu/internal/controllers/typeaheadController.js'; import { DEFAULT_TYPEAHEAD_BUFFER_TIME } from '../../menu/internal/menu.js'; import { getSelectedItems } from './shared.js'; const VALUE = Symbol('value'); // Separate variable needed for closure. const selectBaseClass = mixinDelegatesAria(mixinOnReportValidity(mixinConstraintValidation(mixinFormAssociated(mixinElementInternals(LitElement))))); /** * @fires change {Event} The native `change` event on * [`<input>`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event) * --bubbles * @fires input {InputEvent} The native `input` event on * [`<input>`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) * --bubbles --composed * @fires opening {Event} Fired when the select's menu is about to open. * @fires opened {Event} Fired when the select's menu has finished animations * and opened. * @fires closing {Event} Fired when the select's menu is about to close. * @fires closed {Event} Fired when the select's menu has finished animations * and closed. */ export class Select extends selectBaseClass { /** * 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. */ get value() { return this[VALUE]; } set value(value) { if (isServer) return; this.lastUserSetValue = value; this.select(value); } get options() { // NOTE: this does a DOM query. return (this.menu?.items ?? []); } /** * 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. */ get selectedIndex() { // tslint:disable-next-line:enforce-name-casing const [_option, index] = (this.getSelectedOptions() ?? [])[0] ?? []; return index ?? -1; } set selectedIndex(index) { this.lastUserSetSelectedIndex = index; this.selectIndex(index); } /** * Returns an array of selected options. * * NOTE: md-select only supports single selection. */ get selectedOptions() { return (this.getSelectedOptions() ?? []).map(([option]) => option); } get hasError() { return this.error || this.nativeError; } constructor() { super(); /** * Opens the menu synchronously with no animation. */ this.quick = false; /** * Whether or not the select is required. */ this.required = 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()`. */ this.errorText = ''; /** * The floating label for the field. */ this.label = ''; /** * Disables the asterisk on the floating label, when the select is * required. */ this.noAsterisk = false; /** * Conveys additional information below the select, such as how it should * be used. */ this.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()`. */ this.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`. */ this.menuPositioning = 'popover'; /** * Clamps the menu-width to the width of the select. */ this.clampMenuWidth = false; /** * The max time between the keystrokes of the typeahead select / menu behavior * before it clears the typeahead buffer. */ this.typeaheadDelay = DEFAULT_TYPEAHEAD_BUFFER_TIME; /** * Whether or not the text field has a leading icon. Used for SSR. */ this.hasLeadingIcon = false; /** * Text to display in the field. Only set for SSR. */ this.displayText = ''; /** * Whether the menu should be aligned to the start or the end of the select's * textbox. */ this.menuAlign = 'start'; this[_a] = ''; /** * Used for initializing select when the user sets the `value` directly. */ this.lastUserSetValue = null; /** * Used for initializing select when the user sets the `selectedIndex` * directly. */ this.lastUserSetSelectedIndex = null; /** * Used for `input` and `change` event change detection. */ this.lastSelectedOption = null; // tslint:disable-next-line:enforce-name-casing this.lastSelectedOptionRecords = []; /** * Whether or not a native error has been reported via `reportValidity()`. */ this.nativeError = false; /** * The validation message displayed from a native error via * `reportValidity()`. */ this.nativeErrorText = ''; this.focused = false; this.open = false; this.defaultFocus = FocusState.NONE; // Have to keep track of previous open because it's state and private and thus // cannot be tracked in PropertyValues<this> map. this.prevOpen = this.open; this.selectWidth = 0; if (isServer) { return; } this.addEventListener('focus', this.handleFocus.bind(this)); this.addEventListener('blur', this.handleBlur.bind(this)); } /** * Selects an option given the value of the option, and updates MdSelect's * value. */ select(value) { 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) { 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 = ''; } [(_a = VALUE, onReportValidity)](invalidEvent) { // Prevent default pop-up behavior. invalidEvent?.preventDefault(); const prevMessage = this.getErrorText(); this.nativeError = !!invalidEvent; this.nativeErrorText = this.validationMessage; if (prevMessage === this.getErrorText()) { this.field?.reannounceError(); } } update(changed) { // In SSR the options will be ready to query, so try to figure out what // the value and display text should be. if (!this.hasUpdated) { this.initUserSelection(); } // We have just opened the menu. // We are only able to check for the select's rect in `update()` instead of // having to wait for `updated()` because the menu can never be open on // first render since it is not settable and Lit SSR does not support click // events which would open the menu. if (this.prevOpen !== this.open && this.open) { const selectRect = this.getBoundingClientRect(); this.selectWidth = selectRect.width; } this.prevOpen = this.open; super.update(changed); } render() { return html ` <span class="select ${classMap(this.getRenderClasses())}" @focusout=${this.handleFocusout}> ${this.renderField()} ${this.renderMenu()} </span> `; } async firstUpdated(changed) { await this.menu?.updateComplete; // If this has been handled on update already due to SSR, try again. if (!this.lastSelectedOptionRecords.length) { this.initUserSelection(); } // Case for when the DOM is streaming, there are no children, and a child // has [selected] set on it, we need to wait for DOM to render something. if (!this.lastSelectedOptionRecords.length && !isServer && !this.options.length) { setTimeout(() => { this.updateValueAndDisplayText(); }); } super.firstUpdated(changed); } getRenderClasses() { return { 'disabled': this.disabled, 'error': this.error, 'open': this.open, }; } renderField() { const ariaLabel = this.ariaLabel || this.label; return staticHtml ` <${this.fieldTag} aria-haspopup="listbox" role="combobox" part="field" id="field" tabindex=${this.disabled ? '-1' : '0'} aria-label=${ariaLabel || nothing} aria-describedby="description" aria-expanded=${this.open ? 'true' : 'false'} aria-controls="listbox" class="field" label=${this.label} ?no-asterisk=${this.noAsterisk} .focused=${this.focused || this.open} .populated=${!!this.displayText} .disabled=${this.disabled} .required=${this.required} .error=${this.hasError} ?has-start=${this.hasLeadingIcon} has-end supporting-text=${this.supportingText} error-text=${this.getErrorText()} @keydown=${this.handleKeydown} @click=${this.handleClick}> ${this.renderFieldContent()} <div id="description" slot="aria-describedby"></div> </${this.fieldTag}>`; } renderFieldContent() { return [ this.renderLeadingIcon(), this.renderLabel(), this.renderTrailingIcon(), ]; } renderLeadingIcon() { return html ` <span class="icon leading" slot="start"> <slot name="leading-icon" @slotchange=${this.handleIconChange}></slot> </span> `; } renderTrailingIcon() { return html ` <span class="icon trailing" slot="end"> <slot name="trailing-icon" @slotchange=${this.handleIconChange}> <svg height="5" viewBox="7 10 10 5" focusable="false"> <polygon class="down" stroke="none" fill-rule="evenodd" points="7 10 12 15 17 10"></polygon> <polygon class="up" stroke="none" fill-rule="evenodd" points="7 15 12 10 17 15"></polygon> </svg> </slot> </span> `; } renderLabel() { // need to render &nbsp; so that line-height can apply and give it a // non-zero height return html `<div id="label">${this.displayText || html `&nbsp;`}</div>`; } renderMenu() { const ariaLabel = this.label || this.ariaLabel; return html `<div class="menu-wrapper"> <md-menu id="listbox" .defaultFocus=${this.defaultFocus} role="listbox" tabindex="-1" aria-label=${ariaLabel || nothing} stay-open-on-focusout part="menu" exportparts="focus-ring: menu-focus-ring" anchor="field" style=${styleMap({ '--__menu-min-width': `${this.selectWidth}px`, '--__menu-max-width': this.clampMenuWidth ? `${this.selectWidth}px` : undefined, })} no-navigation-wrap .open=${this.open} .quick=${this.quick} .positioning=${this.menuPositioning} .typeaheadDelay=${this.typeaheadDelay} .anchorCorner=${this.menuAlign === 'start' ? 'end-start' : 'end-end'} .menuCorner=${this.menuAlign === 'start' ? 'start-start' : 'start-end'} @opening=${this.handleOpening} @opened=${this.redispatchEvent} @closing=${this.redispatchEvent} @closed=${this.handleClosed} @close-menu=${this.handleCloseMenu} @request-selection=${this.handleRequestSelection} @request-deselection=${this.handleRequestDeselection}> ${this.renderMenuContent()} </md-menu> </div>`; } renderMenuContent() { return html `<slot></slot>`; } /** * Handles opening the select on keydown and typahead selection when the menu * is closed. */ handleKeydown(event) { if (this.open || this.disabled || !this.menu) { return; } const typeaheadController = this.menu.typeaheadController; const isOpenKey = event.code === 'Space' || event.code === 'ArrowDown' || event.code === 'ArrowUp' || event.code === 'End' || event.code === 'Home' || event.code === 'Enter'; // Do not open if currently typing ahead because the user may be typing the // spacebar to match a word with a space if (!typeaheadController.isTypingAhead && isOpenKey) { event.preventDefault(); this.open = true; // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/#kbd_label switch (event.code) { case 'Space': case 'ArrowDown': case 'Enter': // We will handle focusing last selected item in this.handleOpening() this.defaultFocus = FocusState.NONE; break; case 'End': this.defaultFocus = FocusState.LAST_ITEM; break; case 'ArrowUp': case 'Home': this.defaultFocus = FocusState.FIRST_ITEM; break; default: break; } return; } const isPrintableKey = event.key.length === 1; // Handles typing ahead when the menu is closed by delegating the event to // the underlying menu's typeaheadController if (isPrintableKey) { typeaheadController.onKeydown(event); event.preventDefault(); const { lastActiveRecord } = typeaheadController; if (!lastActiveRecord) { return; } this.labelEl?.setAttribute?.('aria-live', 'polite'); const hasChanged = this.selectItem(lastActiveRecord[TYPEAHEAD_RECORD.ITEM]); if (hasChanged) { this.dispatchInteractionEvents(); } } } handleClick() { this.open = !this.open; } handleFocus() { this.focused = true; } handleBlur() { this.focused = false; } /** * Handles closing the menu when the focus leaves the select's subtree. */ handleFocusout(event) { // Don't close the menu if we are switching focus between menu, // select-option, and field if (event.relatedTarget && isElementInSubtree(event.relatedTarget, this)) { return; } this.open = false; } /** * Gets a list of all selected select options as a list item record array. * * @return An array of selected list option records. */ getSelectedOptions() { if (!this.menu) { this.lastSelectedOptionRecords = []; return null; } const items = this.menu.items; this.lastSelectedOptionRecords = getSelectedItems(items); return this.lastSelectedOptionRecords; } async getUpdateComplete() { await this.menu?.updateComplete; return super.getUpdateComplete(); } /** * Gets the selected options from the DOM, and updates the value and display * text to the first selected option's value and headline respectively. * * @return Whether or not the selected option has changed since last update. */ updateValueAndDisplayText() { const selectedOptions = this.getSelectedOptions() ?? []; // Used to determine whether or not we need to fire an input / change event // which fire whenever the option element changes (value or selectedIndex) // on user interaction. let hasSelectedOptionChanged = false; if (selectedOptions.length) { const [firstSelectedOption] = selectedOptions[0]; hasSelectedOptionChanged = this.lastSelectedOption !== firstSelectedOption; this.lastSelectedOption = firstSelectedOption; this[VALUE] = firstSelectedOption.value; this.displayText = firstSelectedOption.displayText; } else { hasSelectedOptionChanged = this.lastSelectedOption !== null; this.lastSelectedOption = null; this[VALUE] = ''; this.displayText = ''; } return hasSelectedOptionChanged; } /** * Focuses and activates the last selected item upon opening, and resets other * active items. */ async handleOpening(e) { this.labelEl?.removeAttribute?.('aria-live'); this.redispatchEvent(e); // FocusState.NONE means we want to handle focus ourselves and focus the // last selected item. if (this.defaultFocus !== FocusState.NONE) { return; } const items = this.menu.items; const activeItem = getActiveItem(items)?.item; let [selectedItem] = this.lastSelectedOptionRecords[0] ?? [null]; // This is true if the user keys through the list but clicks out of the menu // thus no close-menu event is fired by an item and we can't clean up in // handleCloseMenu. if (activeItem && activeItem !== selectedItem) { activeItem.tabIndex = -1; } // in the case that nothing is selected, focus the first item selectedItem = selectedItem ?? items[0]; if (selectedItem) { selectedItem.tabIndex = 0; selectedItem.focus(); } } redispatchEvent(e) { redispatchEvent(this, e); } handleClosed(e) { this.open = false; this.redispatchEvent(e); } /** * Determines the reason for closing, and updates the UI accordingly. */ handleCloseMenu(event) { const reason = event.detail.reason; const item = event.detail.itemPath[0]; this.open = false; let hasChanged = false; if (reason.kind === 'click-selection') { hasChanged = this.selectItem(item); } else if (reason.kind === 'keydown' && isSelectableKey(reason.key)) { hasChanged = this.selectItem(item); } else { // This can happen on ESC being pressed item.tabIndex = -1; item.blur(); } // Dispatch interaction events since selection has been made via keyboard // or mouse. if (hasChanged) { this.dispatchInteractionEvents(); } } /** * Selects a given option, deselects other options, and updates the UI. * * @return Whether the last selected option has changed. */ selectItem(item) { const selectedOptions = this.getSelectedOptions() ?? []; selectedOptions.forEach(([option]) => { if (item !== option) { option.selected = false; } }); item.selected = true; return this.updateValueAndDisplayText(); } /** * Handles updating selection when an option element requests selection via * property / attribute change. */ handleRequestSelection(event) { const requestingOptionEl = event.target; // No-op if this item is already selected. if (this.lastSelectedOptionRecords.some(([option]) => option === requestingOptionEl)) { return; } this.selectItem(requestingOptionEl); } /** * Handles updating selection when an option element requests deselection via * property / attribute change. */ handleRequestDeselection(event) { const requestingOptionEl = event.target; // No-op if this item is not even in the list of tracked selected items. if (!this.lastSelectedOptionRecords.some(([option]) => option === requestingOptionEl)) { return; } this.updateValueAndDisplayText(); } /** * Attempts to initialize the selected option from user-settable values like * SSR, setting `value`, or `selectedIndex` at startup. */ initUserSelection() { // User has set `.value` directly, but internals have not yet booted up. if (this.lastUserSetValue && !this.lastSelectedOptionRecords.length) { this.select(this.lastUserSetValue); // User has set `.selectedIndex` directly, but internals have not yet // booted up. } else if (this.lastUserSetSelectedIndex !== null && !this.lastSelectedOptionRecords.length) { this.selectIndex(this.lastUserSetSelectedIndex); // Regular boot up! } else { this.updateValueAndDisplayText(); } } handleIconChange() { this.hasLeadingIcon = this.leadingIcons.length > 0; } /** * Dispatches the `input` and `change` events. */ dispatchInteractionEvents() { this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); this.dispatchEvent(new Event('change', { bubbles: true })); } getErrorText() { return this.error ? this.errorText : this.nativeErrorText; } [getFormValue]() { return this.value; } formResetCallback() { this.reset(); } formStateRestoreCallback(state) { this.value = state; } click() { this.field?.click(); } [createValidator]() { return new SelectValidator(() => this); } [getValidityAnchor]() { return this.field; } } /** @nocollapse */ Select.shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; __decorate([ property({ type: Boolean }) ], Select.prototype, "quick", void 0); __decorate([ property({ type: Boolean }) ], Select.prototype, "required", void 0); __decorate([ property({ type: String, attribute: 'error-text' }) ], Select.prototype, "errorText", void 0); __decorate([ property() ], Select.prototype, "label", void 0); __decorate([ property({ type: Boolean, attribute: 'no-asterisk' }) ], Select.prototype, "noAsterisk", void 0); __decorate([ property({ type: String, attribute: 'supporting-text' }) ], Select.prototype, "supportingText", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], Select.prototype, "error", void 0); __decorate([ property({ attribute: 'menu-positioning' }) ], Select.prototype, "menuPositioning", void 0); __decorate([ property({ type: Boolean, attribute: 'clamp-menu-width' }) ], Select.prototype, "clampMenuWidth", void 0); __decorate([ property({ type: Number, attribute: 'typeahead-delay' }) ], Select.prototype, "typeaheadDelay", void 0); __decorate([ property({ type: Boolean, attribute: 'has-leading-icon' }) ], Select.prototype, "hasLeadingIcon", void 0); __decorate([ property({ attribute: 'display-text' }) ], Select.prototype, "displayText", void 0); __decorate([ property({ attribute: 'menu-align' }) ], Select.prototype, "menuAlign", void 0); __decorate([ property() ], Select.prototype, "value", null); __decorate([ property({ type: Number, attribute: 'selected-index' }) ], Select.prototype, "selectedIndex", null); __decorate([ state() ], Select.prototype, "nativeError", void 0); __decorate([ state() ], Select.prototype, "nativeErrorText", void 0); __decorate([ state() ], Select.prototype, "focused", void 0); __decorate([ state() ], Select.prototype, "open", void 0); __decorate([ state() ], Select.prototype, "defaultFocus", void 0); __decorate([ query('.field') ], Select.prototype, "field", void 0); __decorate([ query('md-menu') ], Select.prototype, "menu", void 0); __decorate([ query('#label') ], Select.prototype, "labelEl", void 0); __decorate([ queryAssignedElements({ slot: 'leading-icon', flatten: true }) ], Select.prototype, "leadingIcons", void 0); //# sourceMappingURL=select.js.map