import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import { mdiMagnify } from "@mdi/js"; import Fuse from "fuse.js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, eventOptions, property, query, state, } from "lit/decorators"; import memoizeOne from "memoize-one"; import { tinykeys } from "tinykeys"; 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 { haStyleScrollbar } from "../resources/styles"; import { loadVirtualizer } from "../resources/virtualizer"; import type { HomeAssistant } from "../types"; import "./ha-combo-box-item"; import "./ha-icon"; import "./ha-textfield"; import type { HaTextField } from "./ha-textfield"; 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: RenderItemFunction = ( 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; @state() private _listScrolled = false; @property({ attribute: false, type: Array }) public getItems?: () => PickerComboBoxItem[]; @property({ attribute: false, type: Array }) public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; @property({ attribute: false }) public rowRenderer?: RenderItemFunction; @property({ attribute: "not-found-label", type: String }) public notFoundLabel?: string; @property({ attribute: false }) public searchFn?: PickerComboBoxSearchFn; @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; @query("ha-textfield") private _searchFieldElement?: HaTextField; @state() private _items: PickerComboBoxItemWithLabel[] = []; private _allItems: PickerComboBoxItemWithLabel[] = []; private _selectedItemIndex = -1; static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; private _removeKeyboardShortcuts?: () => void; protected firstUpdated() { this._registerKeyboardShortcuts(); } public willUpdate() { if (!this.hasUpdated) { loadVirtualizer(); this._allItems = this._getItems(); this._items = this._allItems; } } disconnectedCallback() { super.disconnectedCallback(); this._removeKeyboardShortcuts?.(); } protected render() { return html` `; } private _defaultNotFoundItem = memoizeOne( ( label: this["notFoundLabel"], localize?: LocalizeFunc ): PickerComboBoxItemWithLabel => ({ id: NO_MATCHING_ITEMS_FOUND_ID, primary: label || (localize && localize("ui.components.combo-box.no_match")) || "No matching items found", icon_path: mdiMagnify, a11y_label: label || (localize && localize("ui.components.combo-box.no_match")) || "No matching items found", }) ); 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 ?? navigator.language ) ); if (!sortedItems.length) { sortedItems.push( this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) ); } const additionalItems = this._getAdditionalItems(); sortedItems.push(...additionalItems); return sortedItems; }; private _renderItem = (item: PickerComboBoxItem, index: number) => { const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER; return html`
${item.id === NO_MATCHING_ITEMS_FOUND_ID ? DEFAULT_ROW_RENDERER(item, index) : renderer(item, index)}
`; }; @eventOptions({ passive: true }) private _onScrollList(ev) { const top = ev.target.scrollTop ?? 0; this._listScrolled = top > 0; } private get _value() { return this.value || ""; } private _valueSelected = (ev: Event) => { ev.stopPropagation(); const value = (ev.currentTarget as any).value as string; const newValue = value?.trim(); if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { return; } fireEvent(this, "value-changed", { value: newValue }); }; private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => Fuse.createIndex(["search_labels"], states) ); private _filterChanged = (ev: Event) => { const textfield = ev.target as HaTextField; const searchString = textfield.value.trim(); if (!searchString) { this._items = this._allItems; return; } const index = this._fuseIndex(this._allItems); const fuse = new HaFuse( this._allItems, { shouldSort: false, minMatchCharLength: Math.min(searchString.length, 2), }, index ); const results = fuse.multiTermsSearch(searchString); let filteredItems = this._allItems 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._allItems ); } this._items = filteredItems as PickerComboBoxItemWithLabel[]; this._selectedItemIndex = -1; if (this._virtualizerElement) { this._virtualizerElement.scrollTo(0, 0); } }; private _registerKeyboardShortcuts() { this._removeKeyboardShortcuts = tinykeys(this, { ArrowUp: this._selectPreviousItem, ArrowDown: this._selectNextItem, Home: this._selectFirstItem, End: this._selectLastItem, Enter: this._pickSelectedItem, }); } private _focusList() { if (this._selectedItemIndex === -1) { this._selectNextItem(); } } private _selectNextItem = (ev?: KeyboardEvent) => { ev?.stopPropagation(); ev?.preventDefault(); if (!this._virtualizerElement) { return; } this._searchFieldElement?.focus(); const items = this._virtualizerElement.items as PickerComboBoxItem[]; const maxItems = items.length - 1; if (maxItems === -1) { this._resetSelectedItem(); return; } const nextIndex = maxItems === this._selectedItemIndex ? this._selectedItemIndex : this._selectedItemIndex + 1; if (!items[nextIndex]) { return; } if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) { // Skip titles, padding and empty search if (nextIndex === maxItems) { return; } this._selectedItemIndex = nextIndex + 1; } else { this._selectedItemIndex = nextIndex; } this._scrollToSelectedItem(); }; private _selectPreviousItem = (ev: KeyboardEvent) => { ev.stopPropagation(); ev.preventDefault(); if (!this._virtualizerElement) { return; } if (this._selectedItemIndex > 0) { const nextIndex = this._selectedItemIndex - 1; const items = this._virtualizerElement.items as PickerComboBoxItem[]; if (!items[nextIndex]) { return; } if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) { // Skip titles, padding and empty search if (nextIndex === 0) { return; } this._selectedItemIndex = nextIndex - 1; } else { this._selectedItemIndex = nextIndex; } this._scrollToSelectedItem(); } }; private _selectFirstItem = (ev: KeyboardEvent) => { ev.stopPropagation(); if (!this._virtualizerElement || !this._virtualizerElement.items.length) { return; } const nextIndex = 0; if ( (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === NO_MATCHING_ITEMS_FOUND_ID ) { return; } if (typeof this._virtualizerElement.items[nextIndex] === "string") { this._selectedItemIndex = nextIndex + 1; } else { this._selectedItemIndex = nextIndex; } this._scrollToSelectedItem(); }; private _selectLastItem = (ev: KeyboardEvent) => { ev.stopPropagation(); if (!this._virtualizerElement || !this._virtualizerElement.items.length) { return; } const nextIndex = this._virtualizerElement.items.length - 1; if ( (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === NO_MATCHING_ITEMS_FOUND_ID ) { return; } if (typeof this._virtualizerElement.items[nextIndex] === "string") { this._selectedItemIndex = nextIndex - 1; } else { this._selectedItemIndex = nextIndex; } this._scrollToSelectedItem(); }; private _scrollToSelectedItem = () => { this._virtualizerElement ?.querySelector(".selected") ?.classList.remove("selected"); this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end"); requestAnimationFrame(() => { this._virtualizerElement ?.querySelector(`#list-item-${this._selectedItemIndex}`) ?.classList.add("selected"); }); }; private _pickSelectedItem = (ev: KeyboardEvent) => { ev.stopPropagation(); const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem; if ( this._virtualizerElement?.items.length === 1 && firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID ) { fireEvent(this, "value-changed", { value: firstItem.id, }); } if (this._selectedItemIndex === -1) { return; } // if filter button is focused ev.preventDefault(); const item = this._virtualizerElement?.items[ this._selectedItemIndex ] as PickerComboBoxItem; if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { fireEvent(this, "value-changed", { value: item.id }); } }; private _resetSelectedItem() { this._virtualizerElement ?.querySelector(".selected") ?.classList.remove("selected"); this._selectedItemIndex = -1; } static styles = [ haStyleScrollbar, css` :host { display: flex; flex-direction: column; padding-top: var(--ha-space-3); flex: 1; } ha-textfield { padding: 0 var(--ha-space-3); margin-bottom: var(--ha-space-3); } :host([mode="dialog"]) ha-textfield { padding: 0 var(--ha-space-4); } ha-combo-box-item { width: 100%; } ha-combo-box-item.selected { background-color: var(--ha-color-fill-neutral-quiet-hover); } @media (prefers-color-scheme: dark) { ha-combo-box-item.selected { background-color: var(--ha-color-fill-neutral-normal-hover); } } lit-virtualizer { flex: 1; } lit-virtualizer:focus-visible { outline: none; } lit-virtualizer.scrolled { border-top: 1px solid var(--ha-color-border-neutral-quiet); } .bottom-padding { height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); width: 100%; } .empty { text-align: center; } .combo-box-row { display: flex; width: 100%; align-items: center; box-sizing: border-box; min-height: 36px; } .combo-box-row.current-value { background-color: var(--ha-color-fill-primary-quiet-resting); } .combo-box-row.selected { background-color: var(--ha-color-fill-neutral-quiet-hover); } @media (prefers-color-scheme: dark) { .combo-box-row.selected { background-color: var(--ha-color-fill-neutral-normal-hover); } } `, ]; } declare global { interface HTMLElementTagNameMap { "ha-picker-combo-box": HaPickerComboBox; } }