/**
* @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 so that line-height can apply and give it a
// non-zero height
return html `<div id="label">${this.displayText || html ` `}</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