import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import { mdiMagnify, mdiMinusBoxOutline } 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 { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin"; import { multiTermSortedSearch, type FuseWeightedKey, } from "../resources/fuseMultiTerm"; import { haStyleScrollbar } from "../resources/styles"; import { loadVirtualizer } from "../resources/virtualizer"; import type { HomeAssistant } from "../types"; import "./chips/ha-chip-set"; import "./chips/ha-filter-chip"; import "./ha-combo-box-item"; import "./ha-icon"; import "./ha-textfield"; import type { HaTextField } from "./ha-textfield"; export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [ { name: "primary", weight: 10, }, { name: "secondary", weight: 7, }, { name: "id", weight: 3, }, ]; export interface PickerComboBoxItem { id: string; primary: string; secondary?: string; search_labels?: Record; sorting_label?: string; icon_path?: string; icon?: string; } const NO_ITEMS_AVAILABLE_ID = "___no_items_available___"; 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 ScrollableFadeMixin(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({ attribute: false }) public searchKeys?: FuseWeightedKey[]; @state() private _listScrolled = false; @property({ attribute: false }) public getItems!: ( searchString?: string, section?: string ) => (PickerComboBoxItem | string)[]; @property({ attribute: false, type: Array }) public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; @property({ attribute: false }) public rowRenderer?: RenderItemFunction; @property({ attribute: false }) public notFoundLabel?: string | ((search: string) => string); @property({ attribute: "empty-label" }) public emptyLabel?: string; @property({ attribute: false }) public searchFn?: PickerComboBoxSearchFn; @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; /** Section filter buttons for the list, section headers needs to be defined in getItems as strings */ @property({ attribute: false }) public sections?: ( | { id: string; label: string; } | "separator" )[]; @property({ attribute: false }) public sectionTitleFunction?: (listInfo: { firstIndex: number; lastIndex: number; firstItem: PickerComboBoxItem | string; secondItem: PickerComboBoxItem | string; itemsCount: number; }) => string | undefined; @property({ attribute: "selected-section" }) public selectedSection?: string; @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; @query("ha-textfield") private _searchFieldElement?: HaTextField; @state() private _items: (PickerComboBoxItem | string)[] = []; protected get scrollableElement(): HTMLElement | null { return this._virtualizerElement as HTMLElement | null; } @state() private _sectionTitle?: string; @state() private _valuePinned = true; private _allItems: (PickerComboBoxItem | string)[] = []; private _selectedItemIndex = -1; static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; private _removeKeyboardShortcuts?: () => void; private _search = ""; 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` ${this._renderSectionButtons()} ${this.sections?.length ? html`
${this._sectionTitle}
` : nothing}
${this.renderScrollableFades()}
`; } private _renderSectionButtons() { if (!this.sections || this.sections.length === 0) { return nothing; } return html` ${this.sections.map((section) => section === "separator" ? html`
` : html` ` )}
`; } @eventOptions({ passive: true }) private _visibilityChanged(ev) { if ( this._virtualizerElement && this.sectionTitleFunction && this.sections?.length ) { const firstItem = this._virtualizerElement.items[ev.first]; const secondItem = this._virtualizerElement.items[ev.first + 1]; this._sectionTitle = this.sectionTitleFunction({ firstIndex: ev.first, lastIndex: ev.last, firstItem: firstItem as PickerComboBoxItem | string, secondItem: secondItem as PickerComboBoxItem | string, itemsCount: this._virtualizerElement.items.length, }); } } @eventOptions({ passive: true }) private _handleUnpinned() { this._valuePinned = false; } private _getAdditionalItems = (searchString?: string) => this.getAdditionalItems?.(searchString) || []; private _getItems = () => { let items = [...this.getItems(this._search, this.selectedSection)]; if (!this.sections?.length) { items = items.sort((entityA, entityB) => { const sortLabelA = typeof entityA === "string" ? entityA : entityA.sorting_label; const sortLabelB = typeof entityB === "string" ? entityB : entityB.sorting_label; if (!sortLabelA || !sortLabelB) { return 0; } if (!sortLabelB) { return -1; } if (!sortLabelA) { return 1; } return caseInsensitiveStringCompare( sortLabelA, sortLabelB, this.hass?.locale.language ?? navigator.language ); }); } if (!items.length) { items.push(NO_ITEMS_AVAILABLE_ID); } const additionalItems = this._getAdditionalItems(); items.push(...additionalItems); if (this.mode === "dialog") { items.push("padding"); // padding for safe area inset } return items; }; private _renderItem = (item: PickerComboBoxItem | string, index: number) => { if (!item) { return nothing; } if (item === "padding") { return html`
`; } if (item === NO_ITEMS_AVAILABLE_ID) { return html`
${this._search ? typeof this.notFoundLabel === "function" ? this.notFoundLabel(this._search) : this.notFoundLabel || this.hass?.localize("ui.components.combo-box.no_match") || "No matching items found" : this.emptyLabel || this.hass?.localize("ui.components.combo-box.no_items") || "No items available"}
`; } if (typeof item === "string") { return html`
${item}
`; } const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER; return html`
${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(); fireEvent(this, "value-changed", { value: newValue }); }; private _fuseIndex = memoizeOne( (states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) => Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states) ); private _filterChanged = (ev: Event) => { const textfield = ev.target as HaTextField; const searchString = textfield.value.trim(); this._search = searchString; if (this.sections?.length) { this._items = this._getItems(); } else { if (!searchString) { this._items = this._allItems; return; } const index = this._fuseIndex( this._allItems as PickerComboBoxItem[], this.searchKeys ); let filteredItems = multiTermSortedSearch( this._allItems as PickerComboBoxItem[], searchString, this.searchKeys || DEFAULT_SEARCH_KEYS, (item) => item.id, index ) as (PickerComboBoxItem | string)[]; if (!filteredItems.length) { filteredItems.push(NO_ITEMS_AVAILABLE_ID); } const additionalItems = this._getAdditionalItems(); filteredItems.push(...additionalItems); if (this.searchFn) { filteredItems = this.searchFn( searchString, filteredItems as PickerComboBoxItem[], this._allItems as PickerComboBoxItem[] ); } this._items = filteredItems as PickerComboBoxItem[]; } this._selectedItemIndex = -1; if (this._virtualizerElement) { this._virtualizerElement.scrollTo(0, 0); } }; private _toggleSection(ev: Event) { ev.stopPropagation(); this._resetSelectedItem(); this._sectionTitle = undefined; const section = (ev.target as HTMLElement)["section-id"] as string; if (!section) { return; } if (this.selectedSection === section) { this.selectedSection = undefined; } else { this.selectedSection = section; } this._items = this._getItems(); // Reset scroll position when filter changes if (this._virtualizerElement) { this._virtualizerElement.scrollToIndex(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 (typeof items[nextIndex] === "string") { // 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 (typeof items[nextIndex] === "string") { // 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 (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 (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) { 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) { fireEvent(this, "value-changed", { value: item.id }); } }; private _resetSelectedItem() { this._virtualizerElement ?.querySelector(".selected") ?.classList.remove("selected"); this._selectedItemIndex = -1; } private _keyFunction = (item: PickerComboBoxItem | string) => typeof item === "string" ? item : item?.id; private _getInitialSelectedIndex() { if (!this._virtualizerElement || !this.value) { return 0; } const index = this._virtualizerElement.items.findIndex( (item) => typeof item !== "string" && (item as PickerComboBoxItem).id === this.value ); if (index === -1) { return 0; } return index; } static get styles() { return [ ...super.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); } } .virtualizer-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 0; } 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); } } .sections { display: flex; flex-wrap: nowrap; gap: var(--ha-space-2); padding: var(--ha-space-3) var(--ha-space-3); overflow: auto; } :host([mode="dialog"]) .sections { padding: var(--ha-space-3) var(--ha-space-4); } .sections ha-filter-chip { flex-shrink: 0; --md-filter-chip-selected-container-color: var( --ha-color-fill-primary-normal-hover ); color: var(--primary-color); } .sections .separator { height: var(--ha-space-8); width: 0; border: 1px solid var(--ha-color-border-neutral-quiet); } .section-title, .title { background-color: var(--ha-color-fill-neutral-quiet-resting); padding: var(--ha-space-1) var(--ha-space-2); font-weight: var(--ha-font-weight-bold); color: var(--secondary-text-color); min-height: var(--ha-space-6); display: flex; align-items: center; } .title { width: 100%; } :host([mode="dialog"]) .title { padding: var(--ha-space-1) var(--ha-space-4); } :host([mode="dialog"]) ha-textfield { padding: 0 var(--ha-space-4); } .section-title-wrapper { height: 0; position: relative; } .section-title { opacity: 0; position: absolute; top: 1px; width: calc(100% - var(--ha-space-8)); } .section-title.show { opacity: 1; z-index: 1; } .empty-search { display: flex; width: 100%; flex-direction: column; align-items: center; padding: var(--ha-space-3); } `, ]; } } declare global { interface HTMLElementTagNameMap { "ha-picker-combo-box": HaPickerComboBox; } }