UNPKG

@3mo/select-field

Version:

A select field web component

396 lines (385 loc) 12.6 kB
var _a; import { __decorate } from "tslib"; import { html, property, css, event, component, live, query, eventListener, state, ifDefined } from '@a11d/lit'; import { FieldComponent } from '@3mo/field'; import { PopoverFloatingUiPositionController } from '@3mo/popover'; import { FieldSelectValueController } from './SelectValueController.js'; import { Option } from './Option.js'; /** * @element mo-field-select * * @attr default - The default value. * @attr reflectDefault - Whether the default value should be reflected to the attribute. * @attr multiple - Whether multiple options can be selected. * @attr searchable - Whether the options should be searchable. * @attr freeInput - Whether the user can input values that are not in the options. * @attr value - The selected value. * @attr index - The selected index. * @attr data - The selected data. * @attr menuAlignment - Menu popover alignment * @attr menuPlacement - Menu popover placement * * @slot - The select options. * * @csspart input - The input element. * @csspart dropDownIcon - The dropdown icon. * @csspart menu - The menu consisting of list of options. * @csspart list - The list of options. * * @i18n "No results" * * @fires change * @fires input * @fires dataChange * @fires indexChange */ let FieldSelect = class FieldSelect extends FieldComponent { constructor() { super(...arguments); this.dense = false; this.reflectDefault = false; this.multiple = false; this.searchable = false; this.freeInput = false; this.open = false; this[_a] = 0; this.valueController = new FieldSelectValueController(this); } get isPopulated() { const valueNotNullOrEmpty = ['', undefined, null].includes(this.value) === false && (!this.multiple || (this.value instanceof Array && this.value.length > 0)); const hasDefaultOptionAndReflectsDefault = !!this.default && this.reflectDefault; const hasInputValueInFreeInputMode = this.freeInput && !!this.searchString?.trim(); return valueNotNullOrEmpty || hasDefaultOptionAndReflectsDefault || hasInputValueInFreeInputMode; } get isDense() { return this.dense; } get listItems() { return (this.menu?.list?.items ?? []); } get options() { return this.listItems.filter(i => i instanceof Option); } get selectedOptions() { return this.options.filter(o => o.selected); } get isActive() { return super.isActive || this.open; } get showNoOptionsHint() { return this.searchable && !this.freeInput && !!this.searchString && !this.default && !this.options.filter(o => !o.hasAttribute('data-search-no-match')).length; } updated(props) { super.updated(props); this.toggleAttribute('data-show-no-options-hint', this.showNoOptionsHint); } firstUpdated(props) { super.firstUpdated(props); this.menu?.updateComplete.then(async () => { const popover = this.menu?.renderRoot.querySelector('mo-popover'); if (popover?.positionController instanceof PopoverFloatingUiPositionController) { popover.positionController.addMiddleware((await import('./closeWhenOutOfViewport.js')).closeWhenOutOfViewport()); popover.positionController.addMiddleware((await import('./sameInlineSize.js')).sameInlineSize()); } }); } static get styles() { return css ` ${super.styles} :host { display: flex; flex-flow: column; --_grid-column-full-span-in-case: 1 / -1; anchor-name: --mo-field-select; } input { cursor: pointer; } mo-icon[part=dropDownIcon] { font-size: 20px; color: var(--mo-color-gray); user-select: none; margin-inline-end: -4px; cursor: pointer; } mo-field[active] mo-icon[part=dropDownIcon] { color: var(--mo-color-accent); } mo-menu { position-anchor: --mo-field-select; } mo-menu::part(popover) { position-visibility: anchors-visible; background: var(--mo-color-background); max-height: 300px; overflow-y: auto; scrollbar-width: thin; color: var(--mo-color-foreground); min-width: anchor-size(inline); } mo-list-item { min-height: 40px; grid-column: var(--_grid-column-full-span-in-case); } mo-line { grid-column: var(--_grid-column-full-span-in-case); } #no-options-hint { display: none; padding: 10px; color: var(--mo-color-gray); grid-column: var(--_grid-column-full-span-in-case); } :host([data-show-no-options-hint]) #no-options-hint { display: block; } `; } get template() { return html ` ${super.template} ${this.menuTemplate} `; } get inputTemplate() { return this.freeInput || (this.searchable && this.focusController.focused) ? this.searchInputTemplate : this.valueInputTemplate; } get valueInputTemplate() { return html ` <input part='input' id='value' type='text' autocomplete='off' readonly value=${this.valueToInputValue(this.value) || ''} > `; } get searchInputTemplate() { return html ` <input part='input' id='search' type='text' autocomplete='off' ?readonly=${!this.searchable} ?disabled=${this.disabled} .value=${live(this.searchString || '')} @input=${(e) => { this.handleInput(e.target.value, e); }} > `; } get endSlotTemplate() { return html ` ${this.clearIconButtonTemplate} ${super.endSlotTemplate} <mo-icon slot='end' part='dropDownIcon' icon='unfold_more'></mo-icon> `; } get clearIconButtonTemplate() { const clear = () => { this.resetSearch(); this.searchInputElement?.focus(); this.searchInputElement?.select(); }; return !this.searchable || !this.focusController.focused || !this.searchString || this.freeInput || this.valueToInputValue(this.value) === this.searchString ? html.nothing : html ` <mo-icon-button tabIndex='-1' dense slot='end' icon='cancel' style='color: var(--mo-color-gray)' @click=${() => clear()} ></mo-icon-button> `; } get menuTemplate() { return html ` <mo-menu part='menu' exportparts='list' target='field' selectability=${this.multiple ? 'multiple' : 'single'} .anchor=${this} alignment=${ifDefined(this.menuAlignment)} placement=${ifDefined(this.menuPlacement)} ?disabled=${this.disabled} ?open=${this.open} @openChange=${(e) => this.open = e.detail} .value=${this.valueController.menuValue} @change=${(e) => this.handleSelection(e.detail)} @itemsChange=${() => this.handleItemsChange()} > ${this.noResultsOptionTemplate} ${this.defaultOptionTemplate} ${this.optionsTemplate} </mo-menu> `; } get optionsTemplate() { return html ` <slot></slot> `; } get noResultsOptionTemplate() { return html ` <div id='no-options-hint'>${t('No results')}</div> `; } get defaultOptionTemplate() { return !this.default ? html.nothing : html ` <mo-list-item value='' @click=${() => this.handleSelection([])}> ${this.default} </mo-list-item> <mo-line></mo-line> `; } handleOptionRequestValueSync(e) { e.stopPropagation(); this.valueController.requestSync(); } requestValueUpdate() { this.options.forEach(o => o.selected = o.index !== undefined && this.valueController.menuValue.includes(o.index)); this.searchString ?? (this.searchString = this.valueToInputValue(this.value) || undefined); } valueToInputValue(value) { const valueArray = value instanceof Array ? value : value === undefined ? undefined : [value]; return !valueArray || valueArray.length === 0 ? this.reflectDefault ? this.default ?? '' : '' : this.options .filter(o => valueArray.some(v => o.valueMatches(v))) .map(o => o.text).join(', '); } async handleFocus(bubbled, method) { super.handleFocus(bubbled, method); await this.updateComplete; this.searchInputElement?.focus(); this.searchInputElement?.select(); } handleBlur(bubbled, method) { super.handleBlur(bubbled, method); this.resetSearch(); if (method !== 'pointer' && !this.searchable) { this.open = false; } } handleSelection(menuValue) { this.valueController.menuValue = menuValue; this.change.dispatch(this.value); this.dataChange.dispatch(this.data); this.indexChange.dispatch(this.index); this.handleInput(this.valueToInputValue(this.value)); this.resetSearch(); if (!this.multiple) { this.open = false; } } handleItemsChange() { for (const option of this.options) { option.index = this.listItems.indexOf(option); option.multiple = this.multiple; } } setCustomValidity(error) { error; } async checkValidity() { await this.updateComplete; return true; } reportValidity() { } get searchKeyword() { return this.searchString?.toLowerCase().trim() || ''; } async handleInput(value, e) { if (this.open === false) { this.open = true; } this.searchString = value; super.handleInput(value, e); await this.search(); } search() { const matchedValues = this.options .filter(option => option.textMatches(this.searchKeyword)) .map(option => option.normalizedValue); for (const option of this.options) { const matches = matchedValues.some(v => option.valueMatches(v)); option.toggleAttribute('data-search-no-match', !matches); option.disabled = !matches; } return Promise.resolve(); } resetSearch() { if (!this.freeInput) { this.searchString = this.valueToInputValue(this.value); } for (const option of this.options) { option.removeAttribute('data-search-no-match'); option.disabled = false; } } }; _a = FieldSelectValueController.requestSyncKey; __decorate([ event() ], FieldSelect.prototype, "dataChange", void 0); __decorate([ event() ], FieldSelect.prototype, "indexChange", void 0); __decorate([ property() ], FieldSelect.prototype, "default", void 0); __decorate([ property({ type: Boolean }) ], FieldSelect.prototype, "dense", void 0); __decorate([ property({ type: Boolean }) ], FieldSelect.prototype, "reflectDefault", void 0); __decorate([ property({ type: Boolean }) ], FieldSelect.prototype, "multiple", void 0); __decorate([ property({ type: Boolean }) ], FieldSelect.prototype, "searchable", void 0); __decorate([ property({ type: Boolean }) ], FieldSelect.prototype, "freeInput", void 0); __decorate([ property() ], FieldSelect.prototype, "menuAlignment", void 0); __decorate([ property() ], FieldSelect.prototype, "menuPlacement", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], FieldSelect.prototype, "open", void 0); __decorate([ property({ type: String, bindingDefault: true, updated() { this.valueController.value = this.value; } }) ], FieldSelect.prototype, "value", void 0); __decorate([ property({ type: Number, updated() { this.valueController.index = this.index; } }) ], FieldSelect.prototype, "index", void 0); __decorate([ property({ type: Object, updated() { this.valueController.data = this.data; } }) ], FieldSelect.prototype, "data", void 0); __decorate([ state() ], FieldSelect.prototype, "searchString", void 0); __decorate([ state({ updated(value, oldValue) { if (value && value !== oldValue) { this.valueController.sync(); this.requestValueUpdate(); } } }) ], FieldSelect.prototype, _a, void 0); __decorate([ query('input#value') ], FieldSelect.prototype, "valueInputElement", void 0); __decorate([ query('input#search') ], FieldSelect.prototype, "searchInputElement", void 0); __decorate([ query('mo-menu') ], FieldSelect.prototype, "menu", void 0); __decorate([ eventListener('requestSelectValueUpdate') ], FieldSelect.prototype, "handleOptionRequestValueSync", null); FieldSelect = __decorate([ component('mo-field-select') ], FieldSelect); export { FieldSelect };