Files
frontend/src/components/ha-picker-combo-box.ts
2025-11-10 17:15:16 +01:00

744 lines
19 KiB
TypeScript

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 { HaFuse } from "../resources/fuse";
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 interface PickerComboBoxItem {
id: string;
primary: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
icon?: string;
}
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
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 })
public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@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)[] = [];
@state() private _sectionTitle?: string;
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`<ha-textfield
.label=${this.label ??
this.hass?.localize("ui.common.search") ??
"Search"}
@input=${this._filterChanged}
></ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
<div class="section-title-wrapper">
<div
class="section-title ${!this.selectedSection &&
this._sectionTitle
? "show"
: ""}"
>
${this._sectionTitle}
</div>
</div>
`
: nothing}
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer> `;
}
private _renderSectionButtons() {
if (!this.sections || this.sections.length === 0) {
return nothing;
}
return html`
<ha-chip-set class="sections">
${this.sections.map((section) =>
section === "separator"
? html`<div class="separator"></div>`
: html`<ha-filter-chip
@click=${this._toggleSection}
.section-id=${section.id}
.selected=${this.selectedSection === section.id}
.label=${section.label}
>
</ha-filter-chip>`
)}
</ha-chip-set>
`;
}
@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,
});
}
}
private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
(entityA as PickerComboBoxItem).sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!,
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 === "padding") {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
<ha-svg-icon
slot="start"
.path=${this._search ? mdiMagnify : mdiMinusBoxOutline}
></ha-svg-icon>
<span slot="headline"
>${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"}</span
>
</ha-combo-box-item>
</div>
`;
}
if (typeof item === "string") {
return html`<div class="title">${item}</div>`;
}
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`<div
id=${`list-item-${index}`}
class="combo-box-row ${this._value === item.id ? "current-value" : ""}"
.value=${item.id}
.index=${index}
@click=${this._valueSelected}
>
${renderer(item, index)}
</div>`;
};
@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[]) =>
Fuse.createIndex(["search_labels"], 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[]);
const fuse = new HaFuse(
this._allItems as PickerComboBoxItem[],
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = [...this._allItems];
if (results) {
const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
}
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;
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);
}
}
.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;
}
}