chromium/third_party/material_web_components/components-chromium/node_modules/@material/web/select/internal/select.js

/**
 * @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() {
        return staticHtml `
      <${this.fieldTag}
          aria-haspopup="listbox"
          role="combobox"
          part="field"
          id="field"
          tabindex=${this.disabled ? '-1' : '0'}
          aria-label=${this.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