mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-13 21:10:37 +00:00
281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
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<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;
|
|
|
|
@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<PickerComboBoxItem>;
|
|
|
|
@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<PickerComboBoxItem>;
|
|
|
|
@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<PickerComboBoxItemWithLabel>((item) => ({
|
|
...item,
|
|
a11y_label: item.a11y_label || item.primary,
|
|
}));
|
|
};
|
|
|
|
private _getItems = (): PickerComboBoxItemWithLabel[] => {
|
|
const items = this.getItems ? this.getItems() : [];
|
|
|
|
const sortedItems = items
|
|
.map<PickerComboBoxItemWithLabel>((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`
|
|
<ha-combo-box
|
|
item-id-path="id"
|
|
item-value-path="id"
|
|
item-label-path="a11y_label"
|
|
clear-initial-value
|
|
.hass=${this.hass}
|
|
.value=${this._value}
|
|
.label=${this.label}
|
|
.helper=${this.helper}
|
|
.allowCustomValue=${this.allowCustomValue}
|
|
.filteredItems=${this._items}
|
|
.renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER}
|
|
.required=${this.required}
|
|
.disabled=${this.disabled}
|
|
.hideClearIcon=${this.hideClearIcon}
|
|
@opened-changed=${this._openedChanged}
|
|
@value-changed=${this._valueChanged}
|
|
@filter-changed=${this._filterChanged}
|
|
>
|
|
</ha-combo-box>
|
|
`;
|
|
}
|
|
|
|
private get _value() {
|
|
return this.value || "";
|
|
}
|
|
|
|
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
|
ev.stopPropagation();
|
|
if (ev.detail.value !== this._opened) {
|
|
this._opened = ev.detail.value;
|
|
fireEvent(this, "opened-changed", { value: this._opened });
|
|
}
|
|
}
|
|
|
|
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
|
|
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;
|
|
}
|
|
}
|