@3mo/select-field
Version:
A select field web component
396 lines (385 loc) • 12.6 kB
JavaScript
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 || '')}
=${(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)'
=${() => 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}
=${(e) => this.open = e.detail}
.value=${this.valueController.menuValue}
=${(e) => this.handleSelection(e.detail)}
=${() => 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='' =${() => 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 };