/**
* @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 {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {styleMap} from 'lit/directives/style-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 {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 {
CloseMenuEvent,
FocusState,
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 {SelectOption} from './selectoption/select-option.js';
import {
createRequestDeselectionEvent,
createRequestSelectionEvent,
} from './selectoption/selectOptionController.js';
import {getSelectedItems, SelectOptionRecord} 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
* [``](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event)
* --bubbles
* @fires input {InputEvent} The native `input` event on
* [``](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 abstract class Select extends selectBaseClass {
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: 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;
/**
* 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 = '';
/**
* Disables the asterisk on the floating label, when the select is
* required.
*/
@property({type: Boolean, attribute: 'no-asterisk'}) noAsterisk = false;
/**
* 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' | 'popover' = 'popover';
/**
* Clamps the menu-width to the width of the select.
*/
@property({type: Boolean, attribute: 'clamp-menu-width'})
clampMenuWidth = false;
/**
* 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 = '';
/**
* Whether the menu should be aligned to the start or the end of the select's
* textbox.
*/
@property({attribute: 'menu-align'}) menuAlign: 'start' | 'end' = 'start';
/**
* 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 supports single selection.
*/
get selectedOptions() {
return (this.getSelectedOptions() ?? []).map(([option]) => option);
}
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;
@state() private defaultFocus: FocusState = FocusState.NONE;
@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[];
// Have to keep track of previous open because it's state and private and thus
// cannot be tracked in PropertyValues map.
private prevOpen = this.open;
private selectWidth = 0;
constructor() {
super();
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: 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 = '';
}
/** Shows the picker. If it's already open, this is a no-op. */
showPicker() {
this.open = true;
}
override [onReportValidity](invalidEvent: Event | null) {
// 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();
}
}
protected override update(changed: PropertyValues