diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 7096ffc2f6..bf633e938c 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -197,9 +197,6 @@ export class HaDevicePicker extends LitElement { const placeholder = this.placeholder ?? this.hass.localize("ui.components.device-picker.placeholder"); - const notFoundLabel = this.hass.localize( - "ui.components.device-picker.no_match" - ); const valueRenderer = this._valueRenderer(this._configEntryLookup); @@ -209,7 +206,10 @@ export class HaDevicePicker extends LitElement { .autofocus=${this.autofocus} .label=${this.label} .searchLabel=${this.searchLabel} - .notFoundLabel=${notFoundLabel} + .notFoundLabel=${this._notFoundLabel} + .emptyLabel=${this.hass.localize( + "ui.components.device-picker.no_devices" + )} .placeholder=${placeholder} .value=${this.value} .rowRenderer=${this._rowRenderer} @@ -233,6 +233,11 @@ export class HaDevicePicker extends LitElement { this.value = value; fireEvent(this, "value-changed", { value }); } + + private _notFoundLabel = (search: string) => + this.hass.localize("ui.components.device-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 7a37782fc4..36cc96415c 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -269,9 +269,6 @@ export class HaEntityPicker extends LitElement { const placeholder = this.placeholder ?? this.hass.localize("ui.components.entity.entity-picker.placeholder"); - const notFoundLabel = this.hass.localize( - "ui.components.entity.entity-picker.no_match" - ); return html` + this.hass.localize("ui.components.entity.entity-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index e437837dd8..c14746d509 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -271,7 +271,6 @@ export class HaStatisticPicker extends LitElement { const secondary = [areaName, entityName ? deviceName : undefined] .filter(Boolean) .join(isRTL ? " ◂ " : " ▸ "); - const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`; output.push({ @@ -279,7 +278,6 @@ export class HaStatisticPicker extends LitElement { statistic_id: id, primary, secondary, - a11y_label: a11yLabel, stateObj: stateObj, type: "entity", sorting_label: [sortingPrefix, deviceName, entityName].join("_"), @@ -458,9 +456,6 @@ export class HaStatisticPicker extends LitElement { const placeholder = this.placeholder ?? this.hass.localize("ui.components.statistic-picker.placeholder"); - const notFoundLabel = this.hass.localize( - "ui.components.statistic-picker.no_match" - ); return html` + this.hass.localize("ui.components.statistic-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 7c96ed2d90..e2b4ae6db9 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -369,9 +369,10 @@ export class HaAreaPicker extends LitElement { .autofocus=${this.autofocus} .label=${this.label} .helper=${this.helper} - .notFoundLabel=${this.hass.localize( - "ui.components.area-picker.no_match" - )} + .notFoundLabel=${this._notFoundLabel} + .emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")} + .disabled=${this.disabled} + .required=${this.required} .placeholder=${placeholder} .value=${this.value} .getItems=${this._getItems} @@ -425,6 +426,11 @@ export class HaAreaPicker extends LitElement { fireEvent(this, "value-changed", { value }); fireEvent(this, "change"); } + + private _notFoundLabel = (search: string) => + this.hass.localize("ui.components.area-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index 9eef8a2274..88de1bc9eb 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -383,8 +383,9 @@ export class HaFloorPicker extends LitElement { .hass=${this.hass} .autofocus=${this.autofocus} .label=${this.label} - .notFoundLabel=${this.hass.localize( - "ui.components.floor-picker.no_match" + .notFoundLabel=${this._notFoundLabel} + .emptyLabel=${this.hass.localize( + "ui.components.floor-picker.no_floors" )} .placeholder=${placeholder} .value=${this.value} @@ -444,6 +445,11 @@ export class HaFloorPicker extends LitElement { fireEvent(this, "value-changed", { value }); fireEvent(this, "change"); } + + private _notFoundLabel = (search: string) => + this.hass.localize("ui.components.floor-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 06abc08bc0..a160b31ead 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -25,9 +25,6 @@ import "./ha-svg-icon"; export class HaGenericPicker 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; @@ -49,8 +46,11 @@ export class HaGenericPicker extends LitElement { @property({ attribute: "hide-clear-icon", type: Boolean }) public hideClearIcon = false; - @property({ attribute: false, type: Array }) - public getItems?: () => PickerComboBoxItem[]; + @property({ attribute: false }) + public getItems?: ( + searchString?: string, + section?: string + ) => (PickerComboBoxItem | string)[]; @property({ attribute: false, type: Array }) public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; @@ -64,8 +64,11 @@ export class HaGenericPicker extends LitElement { @property({ attribute: false }) public searchFn?: PickerComboBoxSearchFn; - @property({ attribute: "not-found-label", type: String }) - public notFoundLabel?: string; + @property({ attribute: false }) + public notFoundLabel?: string | ((search: string) => string); + + @property({ attribute: "empty-label" }) + public emptyLabel?: string; @property({ attribute: "popover-placement" }) public popoverPlacement: @@ -85,6 +88,25 @@ export class HaGenericPicker extends LitElement { /** If set picker shows an add button instead of textbox when value isn't set */ @property({ attribute: "add-button-label" }) public addButtonLabel?: string; + /** 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(".container") private _containerElement?: HTMLDivElement; @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; @@ -97,6 +119,11 @@ export class HaGenericPicker extends LitElement { @state() private _openedNarrow = false; + static shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + private _narrow = false; // helper to set new value after closing picker, to avoid flicker @@ -189,16 +216,19 @@ export class HaGenericPicker extends LitElement { `; } diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts index 3d199fc49b..07a8c55875 100644 --- a/src/components/ha-label-picker.ts +++ b/src/components/ha-label-picker.ts @@ -224,8 +224,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} .autofocus=${this.autofocus} .label=${this.label} - .notFoundLabel=${this.hass.localize( - "ui.components.label-picker.no_match" + .notFoundLabel=${this._notFoundLabel} + .emptyLabel=${this.hass.localize( + "ui.components.label-picker.no_labels" )} .addButtonLabel=${this.hass.localize("ui.components.label-picker.add")} .placeholder=${placeholder} @@ -288,6 +289,11 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { fireEvent(this, "change"); }, 0); } + + private _notFoundLabel = (search: string) => + this.hass.localize("ui.components.label-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/components/ha-language-picker.ts b/src/components/ha-language-picker.ts index 40fb2a039c..dc129dbf4c 100644 --- a/src/components/ha-language-picker.ts +++ b/src/components/ha-language-picker.ts @@ -125,9 +125,10 @@ export class HaLanguagePicker extends LitElement { .hass=${this.hass} .autofocus=${this.autofocus} popover-placement="bottom-end" - .notFoundLabel=${this.hass?.localize( - "ui.components.language-picker.no_match" - )} + .notFoundLabel=${this._notFoundLabel} + .emptyLabel=${this.hass?.localize( + "ui.components.language-picker.no_languages" + ) || "No languages available"} .placeholder=${this.label ?? (this.hass?.localize("ui.components.language-picker.language") || "Language")} @@ -172,6 +173,15 @@ export class HaLanguagePicker extends LitElement { this.value = ev.detail.value; fireEvent(this, "value-changed", { value: this.value }); } + + private _notFoundLabel = (search: string) => { + const term = html`‘${search}’`; + return this.hass + ? this.hass.localize("ui.components.language-picker.no_match", { + term, + }) + : html`No languages found for ${term}`; + }; } declare global { diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index 7cfa5b9242..fce8befbc3 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -1,6 +1,6 @@ import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; -import { mdiMagnify } from "@mdi/js"; +import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js"; import Fuse from "fuse.js"; import { css, html, LitElement, nothing } from "lit"; import { @@ -14,11 +14,12 @@ 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 "./chips/ha-chip-set"; +import "./chips/ha-filter-chip"; import "./ha-combo-box-item"; import "./ha-icon"; import "./ha-textfield"; @@ -27,28 +28,18 @@ 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 NO_ITEMS_AVAILABLE_ID = "___no_items_available___"; const DEFAULT_ROW_RENDERER: RenderItemFunction = ( item ) => html` - + ${item.icon ? html`` : item.icon_path @@ -87,8 +78,11 @@ export class HaPickerComboBox extends LitElement { @state() private _listScrolled = false; - @property({ attribute: false, type: Array }) - public getItems?: () => PickerComboBoxItem[]; + @property({ attribute: false }) + public getItems?: ( + searchString?: string, + section?: string + ) => (PickerComboBoxItem | string)[]; @property({ attribute: false, type: Array }) public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; @@ -96,21 +90,45 @@ export class HaPickerComboBox extends LitElement { @property({ attribute: false }) public rowRenderer?: RenderItemFunction; - @property({ attribute: "not-found-label", type: String }) - public notFoundLabel?: string; + @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: PickerComboBoxItemWithLabel[] = []; + @state() private _items: (PickerComboBoxItem | string)[] = []; - private _allItems: PickerComboBoxItemWithLabel[] = []; + @state() private _sectionTitle?: string; + + private _allItems: (PickerComboBoxItem | string)[] = []; private _selectedItemIndex = -1; @@ -121,6 +139,8 @@ export class HaPickerComboBox extends LitElement { private _removeKeyboardShortcuts?: () => void; + private _search = ""; + protected firstUpdated() { this._registerKeyboardShortcuts(); } @@ -145,74 +165,142 @@ export class HaPickerComboBox extends LitElement { "Search"} @input=${this._filterChanged} > + ${this._renderSectionButtons()} + ${this.sections?.length + ? html` +
+
+ ${this._sectionTitle} +
+
+ ` + : nothing} `; } - 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 _renderSectionButtons() { + if (!this.sections || this.sections.length === 0) { + return nothing; + } - private _getAdditionalItems = (searchString?: string) => { - const items = this.getAdditionalItems?.(searchString) || []; + return html` + + ${this.sections.map((section) => + section === "separator" + ? html`
` + : html` + ` + )} +
+ `; + } - return items.map((item) => ({ - ...item, - a11y_label: item.a11y_label || item.primary, - })); - }; + @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 _getItems = (): PickerComboBoxItemWithLabel[] => { - const items = this.getItems ? this.getItems() : []; + private _getAdditionalItems = (searchString?: string) => + this.getAdditionalItems?.(searchString) || []; - const sortedItems = items - .map((item) => ({ - ...item, - a11y_label: item.a11y_label || item.primary, - })) - .sort((entityA, entityB) => + private _getItems = () => { + let items = [ + ...(this.getItems + ? this.getItems(this._search, this.selectedSection) + : []), + ]; + + if (!this.sections?.length) { + items = items.sort((entityA, entityB) => caseInsensitiveStringCompare( - entityA.sorting_label!, - entityB.sorting_label!, + (entityA as PickerComboBoxItem).sorting_label!, + (entityB as PickerComboBoxItem).sorting_label!, this.hass?.locale.language ?? navigator.language ) ); + } - if (!sortedItems.length) { - sortedItems.push( - this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) - ); + if (!items.length) { + items.push(NO_ITEMS_AVAILABLE_ID); } const additionalItems = this._getAdditionalItems(); - sortedItems.push(...additionalItems); - return sortedItems; + items.push(...additionalItems); + + if (this.mode === "dialog") { + items.push("padding"); // padding for safe area inset + } + + return items; }; - private _renderItem = (item: PickerComboBoxItem, index: number) => { + private _renderItem = (item: PickerComboBoxItem | string, index: number) => { + 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`
- ${item.id === NO_MATCHING_ITEMS_FOUND_ID - ? DEFAULT_ROW_RENDERER(item, index) - : renderer(item, index)} + ${renderer(item, index)}
`; }; @@ -242,10 +328,6 @@ export class HaPickerComboBox extends LitElement { 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 }); }; @@ -256,51 +338,83 @@ export class HaPickerComboBox extends LitElement { private _filterChanged = (ev: Event) => { const textfield = ev.target as HaTextField; const searchString = textfield.value.trim(); + this._search = searchString; - if (!searchString) { - this._items = this._allItems; - return; - } + if (this.sections?.length) { + this._items = this._getItems(); + } else { + 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 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 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 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[] ); } - const additionalItems = this._getAdditionalItems(searchString); - items.push(...additionalItems); - filteredItems = items; + + this._items = filteredItems as PickerComboBoxItem[]; } - 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 _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, @@ -344,7 +458,7 @@ export class HaPickerComboBox extends LitElement { return; } - if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) { + if (typeof items[nextIndex] === "string") { // Skip titles, padding and empty search if (nextIndex === maxItems) { return; @@ -373,7 +487,7 @@ export class HaPickerComboBox extends LitElement { return; } - if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) { + if (typeof items[nextIndex] === "string") { // Skip titles, padding and empty search if (nextIndex === 0) { return; @@ -395,13 +509,6 @@ export class HaPickerComboBox extends LitElement { 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 { @@ -419,13 +526,6 @@ export class HaPickerComboBox extends LitElement { 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 { @@ -453,10 +553,7 @@ export class HaPickerComboBox extends LitElement { ev.stopPropagation(); const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem; - if ( - this._virtualizerElement?.items.length === 1 && - firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID - ) { + if (this._virtualizerElement?.items.length === 1) { fireEvent(this, "value-changed", { value: firstItem.id, }); @@ -472,7 +569,7 @@ export class HaPickerComboBox extends LitElement { const item = this._virtualizerElement?.items[ this._selectedItemIndex ] as PickerComboBoxItem; - if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { + if (item) { fireEvent(this, "value-changed", { value: item.id }); } }; @@ -484,6 +581,9 @@ export class HaPickerComboBox extends LitElement { this._selectedItemIndex = -1; } + private _keyFunction = (item: PickerComboBoxItem | string) => + typeof item === "string" ? item : item.id; + static styles = [ haStyleScrollbar, css` @@ -558,6 +658,80 @@ export class HaPickerComboBox extends LitElement { 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); + } `, ]; } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index d2eae714b4..2ecb646d29 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -1,15 +1,31 @@ import "@home-assistant/webawesome/dist/components/popover/popover"; +import { consume } from "@lit/context"; // @ts-ignore import chipStyles from "@material/chips/dist/mdc.chips.min.css"; -import { mdiPlaylistPlus } from "@mdi/js"; +import { mdiPlus, mdiTextureBox } from "@mdi/js"; +import Fuse from "fuse.js"; import type { HassServiceTarget } from "home-assistant-js-websocket"; -import type { CSSResultGroup } from "lit"; +import type { CSSResultGroup, PropertyValues } from "lit"; import { LitElement, css, html, nothing, unsafeCSS } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; import { ensureArray } from "../common/array/ensure-array"; import { fireEvent } from "../common/dom/fire_event"; import { isValidEntityId } from "../common/entity/valid_entity_id"; +import { computeRTL } from "../common/util/compute_rtl"; +import { + getAreasAndFloors, + type AreaFloorValue, + type FloorComboBoxItem, +} from "../data/area_floor"; +import { getConfigEntries, type ConfigEntry } from "../data/config_entries"; +import { labelsContext } from "../data/context"; +import { getDevices, type DevicePickerItem } from "../data/device_registry"; import type { HaEntityPickerEntityFilterFunc } from "../data/entity"; +import { getEntities, type EntityComboBoxItem } from "../data/entity_registry"; +import { domainToName } from "../data/integration"; +import { getLabels, type LabelRegistryEntry } from "../data/label_registry"; import { areaMeetsFilter, deviceMeetsFilter, @@ -18,18 +34,23 @@ import { type TargetTypeFloorless, } from "../data/target"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { isHelperDomain } from "../panels/config/helpers/const"; import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail"; +import { HaFuse } from "../resources/fuse"; import type { HomeAssistant } from "../types"; +import { brandsUrl } from "../util/brands-url"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./ha-bottom-sheet"; -import "./ha-button"; -import "./ha-input-helper-text"; +import "./ha-generic-picker"; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import "./ha-svg-icon"; +import "./ha-tree-indicator"; import "./target-picker/ha-target-picker-item-group"; -import "./target-picker/ha-target-picker-selector"; -import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector"; import "./target-picker/ha-target-picker-value-chip"; +const EMPTY_SEARCH = "___EMPTY_SEARCH___"; +const SEPARATOR = "________"; +const CREATE_ID = "___create-new-entity___"; + @customElement("ha-target-picker") export class HaTargetPicker extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @@ -68,23 +89,54 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false; - @state() private _open = false; + @state() private _selectedSection?: TargetTypeFloorless; - @state() private _addTargetWidth = 0; + @state() private _configEntryLookup: Record = {}; - @state() private _narrow = false; - - @state() private _pickerFilter?: TargetTypeFloorless; - - @state() private _pickerWrapperOpen = false; - - @query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement; - - @query("ha-target-picker-selector") - private _targetPickerSelectorElement?: HaTargetPickerSelector; + @state() + @consume({ context: labelsContext, subscribe: true }) + private _labelRegistry!: LabelRegistryEntry[]; private _newTarget?: { type: TargetType; id: string }; + private _getDevicesMemoized = memoizeOne(getDevices); + + private _getLabelsMemoized = memoizeOne(getLabels); + + private _getEntitiesMemoized = memoizeOne(getEntities); + + private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); + + private get _showEntityId() { + return this.hass.userData?.showEntityIdPicker; + } + + private _fuseIndexes = { + area: memoizeOne((states: FloorComboBoxItem[]) => + this._createFuseIndex(states) + ), + entity: memoizeOne((states: EntityComboBoxItem[]) => + this._createFuseIndex(states) + ), + device: memoizeOne((states: DevicePickerItem[]) => + this._createFuseIndex(states) + ), + label: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + }; + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._loadConfigEntries(); + } + } + + private _createFuseIndex = (states) => + Fuse.createIndex(["search_labels"], states); + protected render() { if (this.addOnTop) { return html` ${this._renderPicker()} ${this._renderItems()} `; @@ -289,137 +341,63 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } private _renderPicker() { + const sections = [ + { + id: "entity", + label: this.hass.localize("ui.components.target-picker.type.entities"), + }, + { + id: "device", + label: this.hass.localize("ui.components.target-picker.type.devices"), + }, + { + id: "area", + label: this.hass.localize("ui.components.target-picker.type.areas"), + }, + "separator" as const, + { + id: "label", + label: this.hass.localize("ui.components.target-picker.type.labels"), + }, + ]; + return html`
- - - ${this.hass.localize("ui.components.target-picker.add_target")} - - ${!this._narrow && (this._pickerWrapperOpen || this._open) - ? html` - - ${this._renderTargetSelector()} - - ` - : this._pickerWrapperOpen || this._open - ? html` - ${this._renderTargetSelector(true)} - ` - : nothing} +
- ${this.helper - ? html`${this.helper}` - : nothing} `; } - connectedCallback() { - super.connectedCallback(); - this._handleResize(); - window.addEventListener("resize", this._handleResize); - } - - public disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener("resize", this._handleResize); - } - - private _handleResize = () => { - this._narrow = - window.matchMedia("(max-width: 870px)").matches || - window.matchMedia("(max-height: 500px)").matches; - }; - - private _showPicker() { - this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0; - this._pickerWrapperOpen = true; - } - - // wait for drawer animation to finish - private _showSelector = () => { - this._open = true; - requestAnimationFrame(() => { - this._targetPickerSelectorElement?.focus(); - }); - }; - - private _handleUpdatePickerFilter( - ev: CustomEvent - ) { - this._updatePickerFilter( - typeof ev.detail === "string" ? ev.detail : undefined - ); - } - - private _updatePickerFilter = (filter?: TargetTypeFloorless) => { - this._pickerFilter = filter; - }; - - private _hidePicker(ev) { + private _targetPicked(ev: CustomEvent<{ value: string }>) { ev.stopPropagation(); - this._open = false; - this._pickerWrapperOpen = false; - - if (this._newTarget) { - this._addTarget(this._newTarget.id, this._newTarget.type); - this._newTarget = undefined; + const value = ev.detail.value; + if (value.startsWith(CREATE_ID)) { + this._createNewDomainElement(value.substring(CREATE_ID.length)); + return; } - } - private _renderTargetSelector(dialogMode = false) { - if (!this._open) { - return nothing; - } - return html` - - `; + const [type, id] = ev.detail.value.split(SEPARATOR); + this._addTarget(id, type as TargetType); } private _addTarget(id: string, type: TargetType) { @@ -454,26 +432,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { ?.removeAttribute("collapsed"); } - private _handleTargetPicked = async ( - ev: CustomEvent<{ type: TargetType; id: string }> - ) => { - ev.stopPropagation(); - - this._pickerWrapperOpen = false; - - if (!ev.detail.type || !ev.detail.id) { - return; - } - - // save new target temporarily to add it after dialog closes - this._newTarget = ev.detail; - }; - - private _handleCreateDomain = (ev: CustomEvent) => { - this._pickerWrapperOpen = false; - - const domain = ev.detail; - + private _createNewDomainElement = (domain: string) => { showHelperDetailDialog(this, { domain, dialogClosedCallback: (item) => { @@ -675,6 +634,465 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { return undefined; } + private _getRowType = ( + item: + | PickerComboBoxItem + | (FloorComboBoxItem & { last?: boolean | undefined }) + | EntityComboBoxItem + | DevicePickerItem + ) => { + if ( + (item as FloorComboBoxItem).type === "area" || + (item as FloorComboBoxItem).type === "floor" + ) { + return (item as FloorComboBoxItem).type; + } + + if ("domain" in item) { + return "device"; + } + + if ("stateObj" in item) { + return "entity"; + } + + if (item.id === EMPTY_SEARCH) { + return "empty"; + } + + return "label"; + }; + + private _sectionTitleFunction = ({ + firstIndex, + lastIndex, + firstItem, + secondItem, + itemsCount, + }: { + firstIndex: number; + lastIndex: number; + firstItem: PickerComboBoxItem | string; + secondItem: PickerComboBoxItem | string; + itemsCount: number; + }) => { + if ( + firstItem === undefined || + secondItem === undefined || + typeof firstItem === "string" || + (typeof secondItem === "string" && secondItem !== "padding") || + (firstIndex === 0 && lastIndex === itemsCount - 1) + ) { + return undefined; + } + + const type = this._getRowType(firstItem as PickerComboBoxItem); + const translationType: + | "areas" + | "entities" + | "devices" + | "labels" + | undefined = + type === "area" || type === "floor" + ? "areas" + : type === "entity" + ? "entities" + : type && type !== "empty" + ? `${type}s` + : undefined; + + return translationType + ? this.hass.localize( + `ui.components.target-picker.type.${translationType}` + ) + : undefined; + }; + + private _getItems = (searchString: string, section: string) => { + this._selectedSection = section as TargetTypeFloorless | undefined; + + return this._getItemsMemoized( + this.entityFilter, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.value, + searchString, + this._configEntryLookup, + this._selectedSection + ); + }; + + private _getItemsMemoized = memoizeOne( + ( + entityFilter: this["entityFilter"], + deviceFilter: this["deviceFilter"], + includeDomains: this["includeDomains"], + includeDeviceClasses: this["includeDeviceClasses"], + targetValue: this["value"], + searchTerm: string, + configEntryLookup: Record, + filterType?: TargetTypeFloorless + ) => { + const items: ( + | string + | FloorComboBoxItem + | EntityComboBoxItem + | PickerComboBoxItem + )[] = []; + + if (!filterType || filterType === "entity") { + let entities = this._getEntitiesMemoized( + this.hass, + includeDomains, + undefined, + entityFilter, + includeDeviceClasses, + undefined, + undefined, + targetValue?.entity_id + ? ensureArray(targetValue.entity_id) + : undefined, + undefined, + `entity${SEPARATOR}` + ); + + if (searchTerm) { + entities = this._filterGroup( + "entity", + entities, + searchTerm, + (item: EntityComboBoxItem) => + item.stateObj?.entity_id === searchTerm + ) as EntityComboBoxItem[]; + } + + if (!filterType && entities.length) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.entities") + ); + } + + items.push(...entities); + } + + if (!filterType || filterType === "device") { + let devices = this._getDevicesMemoized( + this.hass, + configEntryLookup, + includeDomains, + undefined, + includeDeviceClasses, + deviceFilter, + entityFilter, + targetValue?.device_id + ? ensureArray(targetValue.device_id) + : undefined, + undefined, + `device${SEPARATOR}` + ); + + if (searchTerm) { + devices = this._filterGroup("device", devices, searchTerm); + } + + if (!filterType && devices.length) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.devices") + ); + } + + items.push(...devices); + } + + if (!filterType || filterType === "area") { + let areasAndFloors = this._getAreasAndFloorsMemoized( + this.hass.states, + this.hass.floors, + this.hass.areas, + this.hass.devices, + this.hass.entities, + memoizeOne((value: AreaFloorValue): string => + [value.type, value.id].join(SEPARATOR) + ), + includeDomains, + undefined, + includeDeviceClasses, + deviceFilter, + entityFilter, + targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined, + targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined + ); + + if (searchTerm) { + areasAndFloors = this._filterGroup( + "area", + areasAndFloors, + searchTerm + ) as FloorComboBoxItem[]; + } + + if (!filterType && areasAndFloors.length) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.areas") + ); + } + + items.push( + ...areasAndFloors.map((item, index) => { + const nextItem = areasAndFloors[index + 1]; + + if ( + !nextItem || + (item.type === "area" && nextItem.type === "floor") + ) { + return { + ...item, + last: true, + }; + } + + return item; + }) + ); + } + + if (!filterType || filterType === "label") { + let labels = this._getLabelsMemoized( + this.hass, + this._labelRegistry, + includeDomains, + undefined, + includeDeviceClasses, + deviceFilter, + entityFilter, + targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined, + `label${SEPARATOR}` + ); + + if (searchTerm) { + labels = this._filterGroup("label", labels, searchTerm); + } + + if (!filterType && labels.length) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.labels") + ); + } + + items.push(...labels); + } + + return items; + } + ); + + private _filterGroup( + type: TargetType, + items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[], + searchTerm: string, + checkExact?: ( + item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem + ) => boolean + ) { + const fuseIndex = this._fuseIndexes[type](items); + const fuse = new HaFuse( + items, + { + shouldSort: false, + minMatchCharLength: Math.min(searchTerm.length, 2), + }, + fuseIndex + ); + + const results = fuse.multiTermsSearch(searchTerm); + let filteredItems = items; + if (results) { + filteredItems = results.map((result) => result.item); + } + + if (!checkExact) { + return filteredItems; + } + + // If there is exact match for entity id, put it first + const index = filteredItems.findIndex((item) => checkExact(item)); + if (index === -1) { + return filteredItems; + } + + const [exactMatch] = filteredItems.splice(index, 1); + filteredItems.unshift(exactMatch); + + return filteredItems; + } + + private _getAdditionalItems = () => this._getCreateItems(this.createDomains); + + private _getCreateItems = memoizeOne( + (createDomains: this["createDomains"]) => { + if (!createDomains?.length) { + return []; + } + + return createDomains.map((domain) => { + const primary = this.hass.localize( + "ui.components.entity.entity-picker.create_helper", + { + domain: isHelperDomain(domain) + ? this.hass.localize(`ui.panel.config.helpers.types.${domain}`) + : domainToName(this.hass.localize, domain), + } + ); + + return { + id: CREATE_ID + domain, + primary: primary, + secondary: this.hass.localize( + "ui.components.entity.entity-picker.new_entity" + ), + icon_path: mdiPlus, + } satisfies EntityComboBoxItem; + }); + } + ); + + private async _loadConfigEntries() { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } + + private _renderRow = ( + item: + | PickerComboBoxItem + | (FloorComboBoxItem & { last?: boolean | undefined }) + | EntityComboBoxItem + | DevicePickerItem, + index: number + ) => { + if (!item) { + return nothing; + } + + const type = this._getRowType(item); + let hasFloor = false; + let rtl = false; + let showEntityId = false; + + if (type === "area" || type === "floor") { + item.id = item[type]?.[`${type}_id`]; + + rtl = computeRTL(this.hass); + hasFloor = + type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; + } + + if (type === "entity") { + showEntityId = !!this._showEntityId; + } + + return html` + + ${(item as FloorComboBoxItem).type === "area" && hasFloor + ? html` + + ` + : nothing} + ${item.icon + ? html`` + : item.icon_path + ? html`` + : type === "entity" && (item as EntityComboBoxItem).stateObj + ? html` + + ` + : type === "device" && (item as DevicePickerItem).domain + ? html` + + ` + : type === "floor" + ? html`` + : type === "area" + ? html`` + : nothing} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${(item as EntityComboBoxItem).stateObj && showEntityId + ? html` + + ${(item as EntityComboBoxItem).stateObj?.entity_id} + + ` + : nothing} + ${(item as EntityComboBoxItem).domain_name && + (type !== "entity" || !showEntityId) + ? html` +
+ ${(item as EntityComboBoxItem).domain_name} +
+ ` + : nothing} +
+ `; + }; + + private _noTargetFoundLabel = (search: string) => + this.hass.localize("ui.components.target-picker.no_target_found", { + term: html`‘${search}’`, + }); + static get styles(): CSSResultGroup { return css` .add-target-wrapper { @@ -683,31 +1101,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { margin-top: var(--ha-space-3); } - wa-popover { - --wa-space-l: var(--ha-space-0); - } - - wa-popover::part(body) { - width: min(max(var(--body-width), 336px), 600px); - max-width: min(max(var(--body-width), 336px), 600px); - max-height: 500px; - height: 70vh; - overflow: hidden; - } - - @media (max-height: 1000px) { - wa-popover::part(body) { - max-height: 400px; - } - } - - ha-bottom-sheet { - --ha-bottom-sheet-height: 90vh; - --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); - --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height); - --ha-bottom-sheet-max-width: 600px; - --ha-bottom-sheet-padding: var(--ha-space-0); - --ha-bottom-sheet-surface-background: var(--card-background-color); + ha-generic-picker { + width: 100%; } ${unsafeCSS(chipStyles)} diff --git a/src/components/target-picker/ha-target-picker-selector.ts b/src/components/target-picker/ha-target-picker-selector.ts deleted file mode 100644 index 94a3db5155..0000000000 --- a/src/components/target-picker/ha-target-picker-selector.ts +++ /dev/null @@ -1,1105 +0,0 @@ -import type { LitVirtualizer } from "@lit-labs/virtualizer"; -import { consume } from "@lit/context"; -import { mdiPlus, mdiTextureBox } from "@mdi/js"; -import Fuse from "fuse.js"; -import type { HassServiceTarget } from "home-assistant-js-websocket"; -import { css, html, LitElement, nothing, type PropertyValues } from "lit"; -import { - customElement, - eventOptions, - property, - query, - state, -} from "lit/decorators"; -import { styleMap } from "lit/directives/style-map"; -import memoizeOne from "memoize-one"; -import { tinykeys } from "tinykeys"; -import { ensureArray } from "../../common/array/ensure-array"; -import { fireEvent } from "../../common/dom/fire_event"; -import type { LocalizeKeys } from "../../common/translations/localize"; -import { computeRTL } from "../../common/util/compute_rtl"; -import { - getAreasAndFloors, - type AreaFloorValue, - type FloorComboBoxItem, -} from "../../data/area_floor"; -import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; -import { labelsContext } from "../../data/context"; -import { getDevices, type DevicePickerItem } from "../../data/device_registry"; -import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; -import { - getEntities, - type EntityComboBoxItem, -} from "../../data/entity_registry"; -import { domainToName } from "../../data/integration"; -import { getLabels, type LabelRegistryEntry } from "../../data/label_registry"; -import type { TargetType, TargetTypeFloorless } from "../../data/target"; -import { - isHelperDomain, - type HelperDomain, -} from "../../panels/config/helpers/const"; -import { HaFuse } from "../../resources/fuse"; -import { haStyleScrollbar } from "../../resources/styles"; -import { loadVirtualizer } from "../../resources/virtualizer"; -import type { HomeAssistant } from "../../types"; -import { brandsUrl } from "../../util/brands-url"; -import "../chips/ha-chip-set"; -import "../chips/ha-filter-chip"; -import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"; -import "../entity/state-badge"; -import "../ha-combo-box-item"; -import "../ha-floor-icon"; -import "../ha-md-list"; -import type { PickerComboBoxItem } from "../ha-picker-combo-box"; -import "../ha-svg-icon"; -import "../ha-textfield"; -import type { HaTextField } from "../ha-textfield"; -import "../ha-tree-indicator"; - -const SEPARATOR = "________"; -const EMPTY_SEARCH = "___EMPTY_SEARCH___"; -const CREATE_ID = "___create-new-entity___"; - -@customElement("ha-target-picker-selector") -export class HaTargetPickerSelector extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property({ attribute: false }) public filterType?: TargetTypeFloorless; - - @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; - - /** - * Show only targets with entities from specific domains. - * @type {Array} - * @attr include-domains - */ - @property({ type: Array, attribute: "include-domains" }) - public includeDomains?: string[]; - - /** - * Show only targets with entities of these device classes. - * @type {Array} - * @attr include-device-classes - */ - @property({ type: Array, attribute: "include-device-classes" }) - public includeDeviceClasses?: string[]; - - @property({ attribute: false }) - public deviceFilter?: HaDevicePickerDeviceFilterFunc; - - @property({ attribute: false }) - public entityFilter?: HaEntityPickerEntityFilterFunc; - - @property({ attribute: false }) public targetValue?: HassServiceTarget; - - @property({ attribute: false, type: Array }) public createDomains?: string[]; - - @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; - - @query("ha-textfield") private _searchFieldElement?: HaTextField; - - @state() private _searchTerm = ""; - - @state() private _listScrolled = false; - - @state() private _configEntryLookup: Record = {}; - - private _selectedItemIndex = -1; - - @state() private _filterHeader?: string; - - @state() - @consume({ context: labelsContext, subscribe: true }) - private _labelRegistry!: LabelRegistryEntry[]; - - private _getDevicesMemoized = memoizeOne(getDevices); - - private _getLabelsMemoized = memoizeOne(getLabels); - - private _getEntitiesMemoized = memoizeOne(getEntities); - - private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); - - static shadowRootOptions = { - ...LitElement.shadowRootOptions, - delegatesFocus: true, - }; - - private _removeKeyboardShortcuts?: () => void; - - public willUpdate(changedProps: PropertyValues) { - super.willUpdate(changedProps); - - if (!this.hasUpdated) { - this._loadConfigEntries(); - loadVirtualizer(); - } - } - - protected firstUpdated() { - this._registerKeyboardShortcuts(); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this._removeKeyboardShortcuts?.(); - } - - private async _loadConfigEntries() { - const configEntries = await getConfigEntries(this.hass); - this._configEntryLookup = Object.fromEntries( - configEntries.map((entry) => [entry.entry_id, entry]) - ); - } - - protected render() { - return html` - - ${this._renderFilterButtons()} -
-
- ${this._filterHeader} -
-
- - - `; - } - - @eventOptions({ passive: true }) - private _visibilityChanged(ev) { - if (this._virtualizerElement) { - const firstItem = this._virtualizerElement.items[ev.first]; - const secondItem = this._virtualizerElement.items[ev.first + 1]; - - if ( - firstItem === undefined || - secondItem === undefined || - typeof firstItem === "string" || - (typeof secondItem === "string" && secondItem !== "padding") || - (ev.first === 0 && - ev.last === this._virtualizerElement.items.length - 1) - ) { - this._filterHeader = undefined; - return; - } - - const type = this._getRowType(firstItem as PickerComboBoxItem); - const translationType: - | "areas" - | "entities" - | "devices" - | "labels" - | undefined = - type === "area" || type === "floor" - ? "areas" - : type === "entity" - ? "entities" - : type && type !== "empty" - ? `${type}s` - : undefined; - - this._filterHeader = translationType - ? (this._filterHeader = this.hass.localize( - `ui.components.target-picker.type.${translationType}` - )) - : undefined; - } - } - - 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; - - 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" || - (items[nextIndex] as PickerComboBoxItem)?.id === EMPTY_SEARCH - ) { - // 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; - - if (!items[nextIndex]) { - return; - } - - if ( - typeof items[nextIndex] === "string" || - (items[nextIndex] as PickerComboBoxItem)?.id === EMPTY_SEARCH - ) { - // 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 === - EMPTY_SEARCH - ) { - 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 === - EMPTY_SEARCH - ) { - 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) => { - if (this._selectedItemIndex === -1) { - return; - } - - // if filter button is focused - ev.preventDefault(); - - const item: any = this._virtualizerElement?.items[this._selectedItemIndex]; - if (item && typeof item !== "string") { - this._pickTarget( - item.id, - "domain" in item - ? "device" - : "stateObj" in item - ? "entity" - : item.type - ? "area" - : "label" - ); - } - }; - - private _renderFilterButtons() { - const filter: (TargetTypeFloorless | "separator")[] = [ - "entity", - "device", - "area", - "separator", - "label", - ]; - return filter.map((filterType) => { - if (filterType === "separator") { - return html`
`; - } - - const selected = this.filterType === filterType; - return html` - - - `; - }); - } - - private _getRowType = ( - item: - | PickerComboBoxItem - | (FloorComboBoxItem & { last?: boolean | undefined }) - | EntityComboBoxItem - | DevicePickerItem - ) => { - if ( - (item as FloorComboBoxItem).type === "area" || - (item as FloorComboBoxItem).type === "floor" - ) { - return (item as FloorComboBoxItem).type; - } - - if ("domain" in item) { - return "device"; - } - - if ("stateObj" in item) { - return "entity"; - } - - if (item.id === EMPTY_SEARCH) { - return "empty"; - } - - return "label"; - }; - - private _renderRow = ( - item: - | PickerComboBoxItem - | (FloorComboBoxItem & { last?: boolean | undefined }) - | EntityComboBoxItem - | DevicePickerItem - | string, - index: number - ) => { - if (!item) { - return nothing; - } - - if (typeof item === "string") { - if (item === "padding") { - return html`
`; - } - return html`
${item}
`; - } - - const type = this._getRowType(item); - let hasFloor = false; - let rtl = false; - let showEntityId = false; - - if (type === "area" || type === "floor") { - item.id = item[type]?.[`${type}_id`]; - - rtl = computeRTL(this.hass); - hasFloor = - type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; - } - - if (type === "entity") { - showEntityId = !!this._showEntityId; - } - - return html` - - ${(item as FloorComboBoxItem).type === "area" && hasFloor - ? html` - - ` - : nothing} - ${item.icon - ? html`` - : item.icon_path - ? html`` - : type === "entity" && (item as EntityComboBoxItem).stateObj - ? html` - - ` - : type === "device" && (item as DevicePickerItem).domain - ? html` - - ` - : type === "floor" - ? html`` - : type === "area" - ? html`` - : nothing} - ${item.primary} - ${item.secondary - ? html`${item.secondary}` - : nothing} - ${(item as EntityComboBoxItem).stateObj && showEntityId - ? html` - - ${(item as EntityComboBoxItem).stateObj?.entity_id} - - ` - : nothing} - ${(item as EntityComboBoxItem).domain_name && - (type !== "entity" || !showEntityId) - ? html` -
- ${(item as EntityComboBoxItem).domain_name} -
- ` - : nothing} -
- `; - }; - - private _filterGroup( - type: TargetType, - items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[], - checkExact?: ( - item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem - ) => boolean - ) { - const fuseIndex = this._fuseIndexes[type](items); - const fuse = new HaFuse( - items, - { - shouldSort: false, - minMatchCharLength: Math.min(this._searchTerm.length, 2), - }, - fuseIndex - ); - - const results = fuse.multiTermsSearch(this._searchTerm); - let filteredItems = items; - if (results) { - filteredItems = results.map((result) => result.item); - } - - if (!checkExact) { - return filteredItems; - } - - // If there is exact match for entity id, put it first - const index = filteredItems.findIndex((item) => checkExact(item)); - if (index === -1) { - return filteredItems; - } - - const [exactMatch] = filteredItems.splice(index, 1); - filteredItems.unshift(exactMatch); - - return filteredItems; - } - - private _keyFunction = ( - item: - | PickerComboBoxItem - | (FloorComboBoxItem & { last?: boolean | undefined }) - | EntityComboBoxItem - | DevicePickerItem - | string - ) => { - if (typeof item === "string") { - return item === "padding" ? "padding" : `title-${item}`; - } - const type = this._getRowType(item); - if (type === "empty") { - return `empty-search`; - } - if (type === "area" || type === "floor") { - return `${type}-${item[type]?.[`${type}_id`]}`; - } - return `${type}-${item.id}`; - }; - - private _getItems = memoizeOne( - ( - entityFilter: this["entityFilter"], - deviceFilter: this["deviceFilter"], - includeDomains: this["includeDomains"], - includeDeviceClasses: this["includeDeviceClasses"], - targetValue: this["targetValue"], - searchTerm: string, - createDomains: this["createDomains"], - configEntryLookup: Record, - mode: this["mode"], - filterType?: TargetTypeFloorless - ) => { - const items: ( - | string - | FloorComboBoxItem - | EntityComboBoxItem - | PickerComboBoxItem - )[] = []; - - if (!filterType || filterType === "entity") { - let entities = this._getEntitiesMemoized( - this.hass, - includeDomains, - undefined, - entityFilter, - includeDeviceClasses, - undefined, - undefined, - targetValue?.entity_id - ? ensureArray(targetValue.entity_id) - : undefined - ); - - if (searchTerm) { - entities = this._filterGroup( - "entity", - entities, - (item: EntityComboBoxItem) => - item.stateObj?.entity_id === searchTerm - ) as EntityComboBoxItem[]; - } - - if (!filterType && entities.length) { - // show group title - items.push( - this.hass.localize("ui.components.target-picker.type.entities") - ); - } - - items.push(...entities); - } - - if (!filterType || filterType === "device") { - let devices = this._getDevicesMemoized( - this.hass, - configEntryLookup, - includeDomains, - undefined, - includeDeviceClasses, - deviceFilter, - entityFilter, - targetValue?.device_id - ? ensureArray(targetValue.device_id) - : undefined - ); - - if (searchTerm) { - devices = this._filterGroup("device", devices); - } - - if (!filterType && devices.length) { - // show group title - items.push( - this.hass.localize("ui.components.target-picker.type.devices") - ); - } - - items.push(...devices); - } - - if (!filterType || filterType === "area") { - let areasAndFloors = this._getAreasAndFloorsMemoized( - this.hass.states, - this.hass.floors, - this.hass.areas, - this.hass.devices, - this.hass.entities, - memoizeOne((value: AreaFloorValue): string => - [value.type, value.id].join(SEPARATOR) - ), - includeDomains, - undefined, - includeDeviceClasses, - deviceFilter, - entityFilter, - targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined, - targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined - ); - - if (searchTerm) { - areasAndFloors = this._filterGroup( - "area", - areasAndFloors - ) as FloorComboBoxItem[]; - } - - if (!filterType && areasAndFloors.length) { - // show group title - items.push( - this.hass.localize("ui.components.target-picker.type.areas") - ); - } - - items.push( - ...areasAndFloors.map((item, index) => { - const nextItem = areasAndFloors[index + 1]; - - if ( - !nextItem || - (item.type === "area" && nextItem.type === "floor") - ) { - return { - ...item, - last: true, - }; - } - - return item; - }) - ); - } - - if (!filterType || filterType === "label") { - let labels = this._getLabelsMemoized( - this.hass, - this._labelRegistry, - includeDomains, - undefined, - includeDeviceClasses, - deviceFilter, - entityFilter, - targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined - ); - - if (searchTerm) { - labels = this._filterGroup("label", labels); - } - - if (!filterType && labels.length) { - // show group title - items.push( - this.hass.localize("ui.components.target-picker.type.labels") - ); - } - - items.push(...labels); - } - - items.push(...this._getCreateItems(createDomains)); - - if (searchTerm && items.length === 0) { - items.push({ - id: EMPTY_SEARCH, - primary: this.hass.localize( - "ui.components.target-picker.no_target_found", - { term: html`
‘${searchTerm}’
` } - ), - }); - } else if (items.length === 0) { - items.push({ - id: EMPTY_SEARCH, - primary: this.hass.localize("ui.components.target-picker.no_targets"), - }); - } - - if (mode === "dialog") { - items.push("padding"); // padding for safe area inset - } - - return items; - } - ); - - private _getCreateItems = memoizeOne( - (createDomains: this["createDomains"]) => { - if (!createDomains?.length) { - return []; - } - - return createDomains.map((domain) => { - const primary = this.hass.localize( - "ui.components.entity.entity-picker.create_helper", - { - domain: isHelperDomain(domain) - ? this.hass.localize( - `ui.panel.config.helpers.types.${domain as HelperDomain}` - ) - : domainToName(this.hass.localize, domain), - } - ); - - return { - id: CREATE_ID + domain, - primary: primary, - secondary: this.hass.localize( - "ui.components.entity.entity-picker.new_entity" - ), - icon_path: mdiPlus, - } satisfies EntityComboBoxItem; - }); - } - ); - - private _fuseIndexes = { - area: memoizeOne((states: FloorComboBoxItem[]) => - this._createFuseIndex(states) - ), - entity: memoizeOne((states: EntityComboBoxItem[]) => - this._createFuseIndex(states) - ), - device: memoizeOne((states: DevicePickerItem[]) => - this._createFuseIndex(states) - ), - label: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) - ), - }; - - private _createFuseIndex = (states) => - Fuse.createIndex(["search_labels"], states); - - private _searchChanged(ev: Event) { - const textfield = ev.target as HaTextField; - const value = textfield.value.trim(); - this._searchTerm = value; - - this._resetSelectedItem(); - } - - private _handlePickTarget = (ev) => { - const id = ev.currentTarget?.targetId as string; - const type = ev.currentTarget?.targetType as TargetType; - - if (!id || !type) { - return; - } - - this._pickTarget(id, type); - }; - - private _pickTarget = (id: string, type: TargetType) => { - if (type === "label" && id === EMPTY_SEARCH) { - return; - } - - if (id.startsWith(CREATE_ID)) { - const domain = id.substring(CREATE_ID.length); - - fireEvent(this, "create-domain-picked", domain); - return; - } - - fireEvent(this, "target-picked", { - id, - type, - }); - }; - - private get _showEntityId() { - return this.hass.userData?.showEntityIdPicker; - } - - private _toggleFilter(ev: any) { - ev.stopPropagation(); - this._resetSelectedItem(); - this._filterHeader = undefined; - const type = ev.target.type as TargetTypeFloorless; - if (!type) { - return; - } - if (this.filterType === type) { - this.filterType = undefined; - } else { - this.filterType = type; - } - - // Reset scroll position when filter changes - if (this._virtualizerElement) { - this._virtualizerElement.scrollToIndex(0); - } - - fireEvent(this, "filter-type-changed", this.filterType); - } - - @eventOptions({ passive: true }) - private _onScrollList(ev) { - const top = ev.target.scrollTop ?? 0; - this._listScrolled = top > 0; - } - - 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); - } - - .filter { - display: flex; - flex-wrap: nowrap; - gap: var(--ha-space-2); - padding: var(--ha-space-3) var(--ha-space-3); - overflow: auto; - } - - :host([mode="dialog"]) .filter { - padding: var(--ha-space-3) var(--ha-space-4); - } - - .filter ha-filter-chip { - flex-shrink: 0; - --md-filter-chip-selected-container-color: var( - --ha-color-fill-primary-normal-hover - ); - color: var(--primary-color); - } - - .filter .separator { - height: var(--ha-space-8); - width: 0; - border: 1px solid var(--ha-color-border-neutral-quiet); - } - - .filter-header, - .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%; - min-height: var(--ha-space-8); - } - - :host([mode="dialog"]) .title { - padding: var(--ha-space-1) var(--ha-space-4); - } - - :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); - } - } - - .filter-header-wrapper { - height: 0; - position: relative; - } - - .filter-header { - opacity: 0; - position: absolute; - top: 1px; - width: calc(100% - var(--ha-space-8)); - } - - .filter-header.show { - opacity: 1; - z-index: 1; - } - - 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; - } - `, - ]; -} - -declare global { - interface HTMLElementTagNameMap { - "ha-target-picker-selector": HaTargetPickerSelector; - } - - interface HASSDomEvents { - "filter-type-changed": TargetTypeFloorless | undefined; - "target-picked": { - type: TargetType; - id: string; - }; - "create-domain-picked": string; - } -} diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts index 2959a37d89..f990ca0c0b 100644 --- a/src/components/user/ha-user-picker.ts +++ b/src/components/user/ha-user-picker.ts @@ -128,9 +128,7 @@ class HaUserPicker extends LitElement { .hass=${this.hass} .autofocus=${this.autofocus} .label=${this.label} - .notFoundLabel=${this.hass.localize( - "ui.components.user-picker.no_match" - )} + .notFoundLabel=${this._notFoundLabel} .placeholder=${placeholder} .value=${this.value} .getItems=${this._getItems} @@ -149,6 +147,11 @@ class HaUserPicker extends LitElement { fireEvent(this, "value-changed", { value }); fireEvent(this, "change"); } + + private _notFoundLabel = (search: string) => + this.hass.localize("ui.components.user-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 056bf9c3a9..0b5b3be251 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -186,7 +186,8 @@ export const getDevices = ( deviceFilter?: HaDevicePickerDeviceFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc, excludeDevices?: string[], - value?: string + value?: string, + idPrefix = "" ): DevicePickerItem[] => { const devices = Object.values(hass.devices); const entities = Object.values(hass.entities); @@ -298,7 +299,7 @@ export const getDevices = ( const domainName = domain ? domainToName(hass.localize, domain) : undefined; return { - id: device.id, + id: `${idPrefix}${device.id}`, label: "", primary: deviceName || diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index a4b3279a68..d6ef7678e1 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -344,7 +344,8 @@ export const getEntities = ( includeUnitOfMeasurement?: string[], includeEntities?: string[], excludeEntities?: string[], - value?: string + value?: string, + idPrefix = "" ): EntityComboBoxItem[] => { let items: EntityComboBoxItem[] = []; @@ -395,10 +396,9 @@ export const getEntities = ( const secondary = [areaName, entityName ? deviceName : undefined] .filter(Boolean) .join(isRTL ? " ◂ " : " ▸ "); - const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); return { - id: entityId, + id: `${idPrefix}${entityId}`, primary: primary, secondary: secondary, domain_name: domainName, @@ -411,7 +411,6 @@ export const getEntities = ( friendlyName, entityId, ].filter(Boolean) as string[], - a11y_label: a11yLabel, stateObj: stateObj, }; }); diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts index 78157cef51..f30bbcbfef 100644 --- a/src/data/label_registry.ts +++ b/src/data/label_registry.ts @@ -108,7 +108,8 @@ export const getLabels = ( includeDeviceClasses?: string[], deviceFilter?: HaDevicePickerDeviceFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc, - excludeLabels?: string[] + excludeLabels?: string[], + idPrefix = "" ): PickerComboBoxItem[] => { if (!labels || labels.length === 0) { return []; @@ -262,7 +263,7 @@ export const getLabels = ( } const items = outputLabels.map((label) => ({ - id: label.label_id, + id: `${idPrefix}${label.label_id}`, primary: label.name, secondary: label.description ?? "", icon: label.icon || undefined, diff --git a/src/panels/config/category/ha-category-picker.ts b/src/panels/config/category/ha-category-picker.ts index b4d987b3cb..f5bb9d633b 100644 --- a/src/panels/config/category/ha-category-picker.ts +++ b/src/panels/config/category/ha-category-picker.ts @@ -1,4 +1,4 @@ -import { mdiTag, mdiPlus } from "@mdi/js"; +import { mdiPlus, mdiTag } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { TemplateResult } from "lit"; import { html, LitElement } from "lit"; @@ -194,8 +194,9 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} .autofocus=${this.autofocus} .label=${this.label} - .notFoundLabel=${this.hass.localize( - "ui.components.category-picker.no_match" + .notFoundLabel=${this._notFoundLabel} + .emptyLabel=${this.hass.localize( + "ui.components.category-picker.no_categories" )} .placeholder=${placeholder} .value=${this.value} @@ -254,6 +255,11 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) { fireEvent(this, "change"); }, 0); } + + private _notFoundLabel = (search: string) => + this.hass.localize("ui.components.category-picker.no_match", { + term: html`‘${search}’`, + }); } declare global { diff --git a/src/translations/en.json b/src/translations/en.json index 90ca254795..c01b5770f2 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -652,7 +652,7 @@ "edit": "Edit", "clear": "Clear", "no_entities": "You don't have any entities", - "no_match": "No matching entities found", + "no_match": "No entities found for {term}", "show_entities": "Show entities", "new_entity": "Create a new entity", "placeholder": "Select an entity", @@ -763,7 +763,7 @@ }, "language-picker": { "language": "Language", - "no_match": "No matching languages found", + "no_match": "No languages found for {term}", "no_languages": "No languages available" }, "tts-picker": { @@ -775,7 +775,7 @@ "none": "None" }, "user-picker": { - "no_match": "No matching users found", + "no_match": "No users found for {term}", "user": "User", "add_user": "Add user" }, @@ -786,8 +786,8 @@ "clear": "Clear", "toggle": "Toggle", "show_devices": "Show devices", - "no_devices": "You don't have any devices", - "no_match": "No matching devices found", + "no_devices": "No devices available", + "no_match": "No devices found for {term}", "device": "Device", "unnamed_device": "Unnamed device", "no_area": "No area", @@ -801,8 +801,8 @@ "add_category": "Add category", "add_new_sugestion": "Add new category ''{name}''", "add_new": "Add new category…", - "no_categories": "You don't have any categories", - "no_match": "No matching categories found", + "no_categories": "No categories available", + "no_match": "No categories found for {term}", "add_dialog": { "title": "Add new category", "text": "Enter the name of the new category.", @@ -817,8 +817,8 @@ "add_new_sugestion": "Add new label ''{name}''", "add_new": "Add new label…", "add": "Add label", - "no_labels": "You don't have any labels", - "no_match": "No matching labels found", + "no_labels": "No labels available", + "no_match": "No labels found for {term}", "failed_create_label": "Failed to create label." }, "area-picker": { @@ -827,8 +827,8 @@ "area": "Area", "add_new_sugestion": "Add new area ''{name}''", "add_new": "Add new area…", - "no_areas": "You don't have any areas", - "no_match": "No matching areas found", + "no_areas": "No areas available", + "no_match": "No areas found for {term}", "unassigned_areas": "Unassigned areas", "failed_create_area": "Failed to create area." }, @@ -838,8 +838,8 @@ "floor": "Floor", "add_new_sugestion": "Add new floor ''{name}''", "add_new": "Add new floor…", - "no_floors": "You don't have any floors", - "no_match": "No matching floors found", + "no_floors": "No floors available", + "no_match": "No floors found for {term}", "failed_create_floor": "Failed to create floor." }, "area-filter": { @@ -853,8 +853,8 @@ "statistic-picker": { "statistic": "Statistic", "placeholder": "Select a statistic", - "no_statistics": "You don't have any statistics", - "no_match": "No matching statistics found", + "no_statistics": "No statistics available", + "no_match": "No statistics found for {term}", "no_state": "Entity without state", "missing_entity": "Why is my entity not listed?", "learn_more": "Learn more about statistics" @@ -1292,7 +1292,8 @@ "add": "Add interaction" }, "combo-box": { - "no_match": "No matching items found" + "no_match": "No matching items found", + "no_items": "No items available" }, "suggest_with_ai": { "label": "Suggest",