import { mdiMagnify } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import Fuse from "fuse.js"; import type { PropertyValues, TemplateResult } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import type { LocalizeFunc } from "../common/translations/localize"; import { HaFuse } from "../resources/fuse"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import "./ha-combo-box"; import type { HaComboBox } from "./ha-combo-box"; import "./ha-combo-box-item"; import "./ha-icon"; export interface PickerComboBoxItem { id: string; primary: string; a11y_label?: string; secondary?: string; search_labels?: string[]; sorting_label?: string; icon_path?: string; icon?: string; } // Hack to force empty label to always display empty value by default in the search field export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem { a11y_label: string; } const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___"; const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer = ( item ) => html` ${item.icon ? html`` : item.icon_path ? html`` : nothing} ${item.primary} ${item.secondary ? html`${item.secondary}` : nothing} `; export type PickerComboBoxSearchFn = ( search: string, filteredItems: T[], allItems: T[] ) => T[]; @customElement("ha-picker-combo-box") export class HaPickerComboBox extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; // eslint-disable-next-line lit/no-native-attributes @property({ type: Boolean }) public autofocus = false; @property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public required = false; @property({ type: Boolean, attribute: "allow-custom-value" }) public allowCustomValue; @property() public label?: string; @property() public value?: string; @property() public helper?: string; @property({ attribute: false, type: Array }) public getItems?: () => PickerComboBoxItem[]; @property({ attribute: false, type: Array }) public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; @property({ attribute: false }) public rowRenderer?: ComboBoxLitRenderer; @property({ attribute: "hide-clear-icon", type: Boolean }) public hideClearIcon = false; @property({ attribute: "not-found-label", type: String }) public notFoundLabel?: string; @property({ attribute: false }) public searchFn?: PickerComboBoxSearchFn; @state() private _opened = false; @query("ha-combo-box", true) public comboBox!: HaComboBox; public async open() { await this.updateComplete; await this.comboBox?.open(); } public async focus() { await this.updateComplete; await this.comboBox?.focus(); } private _initialItems = false; private _items: PickerComboBoxItemWithLabel[] = []; private _defaultNotFoundItem = memoizeOne( ( label: this["notFoundLabel"], localize: LocalizeFunc ): PickerComboBoxItemWithLabel => ({ id: NO_MATCHING_ITEMS_FOUND_ID, primary: label || localize("ui.components.combo-box.no_match"), icon_path: mdiMagnify, a11y_label: label || localize("ui.components.combo-box.no_match"), }) ); private _getAdditionalItems = (searchString?: string) => { const items = this.getAdditionalItems?.(searchString) || []; return items.map((item) => ({ ...item, a11y_label: item.a11y_label || item.primary, })); }; private _getItems = (): PickerComboBoxItemWithLabel[] => { const items = this.getItems ? this.getItems() : []; const sortedItems = items .map((item) => ({ ...item, a11y_label: item.a11y_label || item.primary, })) .sort((entityA, entityB) => caseInsensitiveStringCompare( entityA.sorting_label!, entityB.sorting_label!, this.hass.locale.language ) ); if (!sortedItems.length) { sortedItems.push( this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) ); } const additionalItems = this._getAdditionalItems(); sortedItems.push(...additionalItems); return sortedItems; }; protected shouldUpdate(changedProps: PropertyValues) { if ( changedProps.has("value") || changedProps.has("label") || changedProps.has("disabled") ) { return true; } return !(!changedProps.has("_opened") && this._opened); } public willUpdate(changedProps: PropertyValues) { if (changedProps.has("_opened") && this._opened) { this._items = this._getItems(); if (this._initialItems) { this.comboBox.filteredItems = this._items; } this._initialItems = true; } } protected render(): TemplateResult { return html` `; } private get _value() { return this.value || ""; } private _openedChanged(ev: ValueChangedEvent) { ev.stopPropagation(); if (ev.detail.value !== this._opened) { this._opened = ev.detail.value; fireEvent(this, "opened-changed", { value: this._opened }); } } private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); // Clear the input field to prevent showing the old value next time this.comboBox.setTextFieldValue(""); const newValue = ev.detail.value?.trim(); if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { return; } if (newValue !== this._value) { this._setValue(newValue); } } private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => Fuse.createIndex(["search_labels"], states) ); private _filterChanged(ev: CustomEvent): void { if (!this._opened) return; const target = ev.target as HaComboBox; const searchString = ev.detail.value.trim() as string; const index = this._fuseIndex(this._items); const fuse = new HaFuse(this._items, { shouldSort: false }, index); const results = fuse.multiTermsSearch(searchString); let filteredItems = this._items as PickerComboBoxItem[]; if (results) { const items = results.map((result) => result.item); if (items.length === 0) { items.push( this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) ); } const additionalItems = this._getAdditionalItems(searchString); items.push(...additionalItems); filteredItems = items; } if (this.searchFn) { filteredItems = this.searchFn(searchString, filteredItems, this._items); } target.filteredItems = filteredItems; } private _setValue(value: string | undefined) { setTimeout(() => { fireEvent(this, "value-changed", { value }); }, 0); } } declare global { interface HTMLElementTagNameMap { "ha-picker-combo-box": HaPickerComboBox; } }