From 4de95f6710bfe2625b76dc1d244c672e2019eb7c Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 20 May 2025 12:47:33 +0200 Subject: [PATCH] Improve label picker UI and search (#25522) --- src/components/ha-label-picker.ts | 375 ++++++++---------- src/components/ha-labels-picker.ts | 15 +- src/components/ha-target-picker.ts | 5 +- .../areas/dialog-area-registry-detail.ts | 3 + .../config/automation/ha-automation-picker.ts | 1 - .../devices/ha-config-devices-dashboard.ts | 1 - .../config/entities/ha-config-entities.ts | 1 - .../config/helpers/ha-config-helpers.ts | 1 - .../config/labels/dialog-label-detail.ts | 21 +- .../config/labels/show-dialog-label-detail.ts | 4 +- src/panels/config/scene/ha-scene-dashboard.ts | 1 - src/panels/config/script/ha-script-picker.ts | 1 - src/translations/en.json | 7 +- 13 files changed, 198 insertions(+), 238 deletions(-) diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts index 4d941226cc..a5360cc34a 100644 --- a/src/components/ha-label-picker.ts +++ b/src/components/ha-label-picker.ts @@ -1,13 +1,11 @@ -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { mdiLabel, mdiPlus } from "@mdi/js"; import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, html, nothing } from "lit"; +import type { TemplateResult } from "lit"; +import { LitElement, html } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeDomain } from "../common/entity/compute_domain"; -import type { ScorableTextItem } from "../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../common/string/filter/sequence-matching"; import type { DeviceEntityDisplayLookup, DeviceRegistryEntry, @@ -19,30 +17,19 @@ import { createLabelRegistryEntry, subscribeLabelRegistry, } from "../data/label_registry"; +import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./ha-combo-box"; -import type { HaComboBox } from "./ha-combo-box"; -import "./ha-combo-box-item"; -import "./ha-icon-button"; +import "./ha-generic-picker"; +import type { HaGenericPicker } from "./ha-generic-picker"; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; +import type { PickerValueRenderer } from "./ha-picker-field"; import "./ha-svg-icon"; -type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry; - const ADD_NEW_ID = "___ADD_NEW___"; -const NO_LABELS_ID = "___NO_LABELS___"; -const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___"; - -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - ${item.icon - ? html`` - : nothing} - ${item.name} - -`; +const NO_LABELS = "___NO_LABELS___"; @customElement("ha-label-picker") export class HaLabelPicker extends SubscribeMixin(LitElement) { @@ -101,24 +88,13 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { @property({ type: Boolean }) public required = false; - @state() private _opened?: boolean; - @state() private _labels?: LabelRegistryEntry[]; - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - private _suggestion?: string; - - private _init = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public async open() { await this.updateComplete; - await this.comboBox?.open(); - } - - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); + await this._picker?.open(); } protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { @@ -129,20 +105,61 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { ]; } + private _labelMap = memoizeOne( + ( + labels: LabelRegistryEntry[] | undefined + ): Map => { + if (!labels) { + return new Map(); + } + return new Map(labels.map((label) => [label.label_id, label])); + } + ); + + private _valueRenderer: PickerValueRenderer = (value) => { + const label = this._labelMap(this._labels).get(value); + + if (!label) { + return html` + + ${value} + `; + } + + return html` + ${label.icon + ? html`` + : html``} + ${label.name} + `; + }; + private _getLabels = memoizeOne( ( - labels: LabelRegistryEntry[], - areas: HomeAssistant["areas"], - devices: DeviceRegistryEntry[], - entities: EntityRegistryDisplayEntry[], + labels: LabelRegistryEntry[] | undefined, + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], deviceFilter: this["deviceFilter"], entityFilter: this["entityFilter"], - noAdd: this["noAdd"], excludeLabels: this["excludeLabels"] - ): LabelRegistryEntry[] => { + ): PickerComboBoxItem[] => { + if (!labels || labels.length === 0) { + return [ + { + id: NO_LABELS, + primary: this.hass.localize("ui.components.label-picker.no_labels"), + icon_path: mdiLabel, + }, + ]; + } + + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let inputDevices: DeviceRegistryEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined; @@ -274,7 +291,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { if (areaIds) { areaIds.forEach((areaId) => { - const area = areas[areaId]; + const area = haAreas[areaId]; area.labels.forEach((label) => usedLabels.add(label)); }); } @@ -291,192 +308,144 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { ); } - if (!outputLabels.length) { - outputLabels = [ - { - label_id: NO_LABELS_ID, - name: this.hass.localize("ui.components.label-picker.no_match"), - icon: null, - color: null, - description: null, - created_at: 0, - modified_at: 0, - }, - ]; - } + const items = outputLabels.map((label) => ({ + id: label.label_id, + primary: label.name, + icon: label.icon || undefined, + icon_path: label.icon ? undefined : mdiLabel, + sorting_label: label.name, + search_labels: [label.name, label.label_id, label.description].filter( + (v): v is string => Boolean(v) + ), + })); - return noAdd - ? outputLabels - : [ - ...outputLabels, - { - label_id: ADD_NEW_ID, - name: this.hass.localize("ui.components.label-picker.add_new"), - icon: "mdi:plus", - color: null, - description: null, - created_at: 0, - modified_at: 0, - }, - ]; + return items; } ); - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass && this._labels) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const items = this._getLabels( - this._labels!, - this.hass.areas, - Object.values(this.hass.devices), - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeLabels - ).map((label) => ({ - ...label, - strings: [label.label_id, label.name], - })); + private _getItems = () => + this._getLabels( + this._labels, + this.hass.areas, + this.hass.devices, + this.hass.entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeLabels + ); - this.comboBox.items = items; - this.comboBox.filteredItems = items; + private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { + if (!labels) { + return []; } - } + return [ + ...new Set( + labels + .map((label) => label.name.toLowerCase()) + .filter(Boolean) as string[] + ), + ]; + }); + + private _getAdditionalItems = ( + searchString?: string + ): PickerComboBoxItem[] => { + if (this.noAdd) { + return []; + } + + const allLabelNames = this._allLabelNames(this._labels); + + if (searchString && !allLabelNames.includes(searchString.toLowerCase())) { + return [ + { + id: ADD_NEW_ID + searchString, + primary: this.hass.localize( + "ui.components.label-picker.add_new_sugestion", + { + name: searchString, + } + ), + icon_path: mdiPlus, + }, + ]; + } + + return [ + { + id: ADD_NEW_ID, + primary: this.hass.localize("ui.components.label-picker.add_new"), + icon_path: mdiPlus, + }, + ]; + }; protected render(): TemplateResult { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.label-picker.label"); + return html` - label.label_id === this.placeholder) - ?.name - : undefined} - .renderer=${rowRenderer} - @filter-changed=${this._filterChanged} - @opened-changed=${this._openedChanged} - @value-changed=${this._labelChanged} + .autofocus=${this.autofocus} + .label=${this.label} + .notFoundLabel=${this.hass.localize( + "ui.components.label-picker.no_match" + )} + .placeholder=${placeholder} + .value=${this.value} + .getItems=${this._getItems} + .getAdditionalItems=${this._getAdditionalItems} + .valueRenderer=${this._valueRenderer} + @value-changed=${this._valueChanged} > - + `; } - private _filterChanged(ev: CustomEvent): void { - const target = ev.target as HaComboBox; - const filterString = ev.detail.value; - if (!filterString) { - this.comboBox.filteredItems = this.comboBox.items; - return; - } - - const filteredItems = fuzzyFilterSort( - filterString, - target.items?.filter( - (item) => ![NO_LABELS_ID, ADD_NEW_ID].includes(item.label_id) - ) || [] - ); - if (filteredItems.length === 0) { - if (this.noAdd) { - this.comboBox.filteredItems = [ - { - label_id: NO_LABELS_ID, - name: this.hass.localize("ui.components.label-picker.no_match"), - icon: null, - color: null, - }, - ] as ScorableLabelItem[]; - } else { - this._suggestion = filterString; - this.comboBox.filteredItems = [ - { - label_id: ADD_NEW_SUGGESTION_ID, - name: this.hass.localize( - "ui.components.label-picker.add_new_sugestion", - { name: this._suggestion } - ), - icon: "mdi:plus", - color: null, - }, - ] as ScorableLabelItem[]; - } - } else { - this.comboBox.filteredItems = filteredItems; - } - } - - private get _value() { - return this.value || ""; - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _labelChanged(ev: ValueChangedEvent) { + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); - let newValue = ev.detail.value; - if (newValue === NO_LABELS_ID) { - newValue = ""; - this.comboBox.setInputValue(""); + const value = ev.detail.value; + + if (value === NO_LABELS) { return; } - if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { - if (newValue !== this._value) { - this._setValue(newValue); - } + if (!value) { + this._setValue(undefined); return; } - (ev.target as any).value = this._value; + if (value.startsWith(ADD_NEW_ID)) { + this.hass.loadFragmentTranslation("config"); - this.hass.loadFragmentTranslation("config"); + const suggestedName = value.substring(ADD_NEW_ID.length); - showLabelDetailDialog(this, { - entry: undefined, - suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", - createEntry: async (values) => { - const label = await createLabelRegistryEntry(this.hass, values); - const labels = [...this._labels!, label]; - this.comboBox.filteredItems = this._getLabels( - labels, - this.hass.areas!, - Object.values(this.hass.devices)!, - Object.values(this.hass.entities)!, - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeLabels - ); - await this.updateComplete; - await this.comboBox.updateComplete; - this._setValue(label.label_id); - return label; - }, - }); + showLabelDetailDialog(this, { + suggestedName: suggestedName, + createEntry: async (values) => { + try { + const label = await createLabelRegistryEntry(this.hass, values); + this._setValue(label.label_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.label-picker.failed_create_label" + ), + text: err.message, + }); + } + }, + }); + return; + } - this._suggestion = undefined; - this.comboBox.setInputValue(""); + this._setValue(value); } private _setValue(value?: string) { diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts index 81590f3247..3d2db17271 100644 --- a/src/components/ha-labels-picker.ts +++ b/src/components/ha-labels-picker.ts @@ -122,6 +122,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { this.hass.locale.language ); return html` + ${this.label ? html`` : nothing} ${labels?.length ? html` ${repeat( @@ -157,9 +158,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { .helper=${this.helper} .disabled=${this.disabled} .required=${this.required} - .label=${this.label === undefined && this.hass - ? this.hass.localize("ui.components.label-picker.add_label") - : this.label} .placeholder=${this.placeholder} .excludeLabels=${this.value} @value-changed=${this._labelChanged} @@ -182,12 +180,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { showLabelDetailDialog(this, { entry: label, updateEntry: async (values) => { - const updated = await updateLabelRegistryEntry( - this.hass, - label.label_id, - values - ); - return updated; + await updateLabelRegistryEntry(this.hass, label.label_id, values); }, }); } @@ -219,6 +212,10 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { --ha-input-chip-selected-container-opacity: 0.5; --md-input-chip-selected-outline-width: 1px; } + label { + display: block; + margin: 0 0 8px; + } `; } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 9d3652f78a..e7a98128b7 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -441,7 +441,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} id="input" .type=${"label_id"} - .label=${this.hass.localize( + .placeholder=${this.hass.localize( + "ui.components.target-picker.add_label_id" + )} + .searchLabel=${this.hass.localize( "ui.components.target-picker.add_label_id" )} no-add diff --git a/src/panels/config/areas/dialog-area-registry-detail.ts b/src/panels/config/areas/dialog-area-registry-detail.ts index 2fdfefeb88..a7c0ac55de 100644 --- a/src/panels/config/areas/dialog-area-registry-detail.ts +++ b/src/panels/config/areas/dialog-area-registry-detail.ts @@ -142,6 +142,9 @@ class DialogAreaDetail extends LitElement { .hass=${this.hass} .value=${this._labels} @value-changed=${this._labelsChanged} + .placeholder=${this.hass.localize( + "ui.panel.config.areas.editor.add_labels" + )} > { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); - return label; }, }); }; diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 5d6d467cec..ad69a73087 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -1110,7 +1110,6 @@ ${rejected createEntry: async (values) => { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); - return label; }, }); }; diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 310dbae533..252f1a9b24 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -1367,7 +1367,6 @@ ${rejected createEntry: async (values) => { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); - return label; }, }); }; diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index b6de6abe4a..5e4e386f52 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -1259,7 +1259,6 @@ ${rejected createEntry: async (values) => { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); - return label; }, }); }; diff --git a/src/panels/config/labels/dialog-label-detail.ts b/src/panels/config/labels/dialog-label-detail.ts index a4fc0e189c..d511fb937f 100644 --- a/src/panels/config/labels/dialog-label-detail.ts +++ b/src/panels/config/labels/dialog-label-detail.ts @@ -4,20 +4,17 @@ import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-alert"; -import { createCloseHeading } from "../../../components/ha-dialog"; -import "../../../components/ha-switch"; -import "../../../components/ha-textfield"; -import "../../../components/ha-textarea"; -import "../../../components/ha-icon-picker"; import "../../../components/ha-color-picker"; +import { createCloseHeading } from "../../../components/ha-dialog"; +import "../../../components/ha-icon-picker"; +import "../../../components/ha-switch"; +import "../../../components/ha-textarea"; +import "../../../components/ha-textfield"; +import type { LabelRegistryEntryMutableParams } from "../../../data/label_registry"; import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import { haStyleDialog } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import type { LabelDetailDialogParams } from "./show-dialog-label-detail"; -import type { - LabelRegistryEntry, - LabelRegistryEntryMutableParams, -} from "../../../data/label_registry"; @customElement("dialog-label-detail") class DialogLabelDetail @@ -177,7 +174,6 @@ class DialogLabelDetail private async _updateEntry() { this._submitting = true; - let newValue: LabelRegistryEntry | undefined; try { const values: LabelRegistryEntryMutableParams = { name: this._name.trim(), @@ -186,9 +182,9 @@ class DialogLabelDetail description: this._description.trim() || null, }; if (this._params!.entry) { - newValue = await this._params!.updateEntry!(values); + await this._params!.updateEntry!(values); } else { - newValue = await this._params!.createEntry!(values); + await this._params!.createEntry!(values); } this.closeDialog(); } catch (err: any) { @@ -196,7 +192,6 @@ class DialogLabelDetail } finally { this._submitting = false; } - return newValue; } private async _deleteEntry() { diff --git a/src/panels/config/labels/show-dialog-label-detail.ts b/src/panels/config/labels/show-dialog-label-detail.ts index a16f8089f4..ca0f66463e 100644 --- a/src/panels/config/labels/show-dialog-label-detail.ts +++ b/src/panels/config/labels/show-dialog-label-detail.ts @@ -10,10 +10,10 @@ export interface LabelDetailDialogParams { createEntry?: ( values: LabelRegistryEntryMutableParams, labelId?: string - ) => Promise; + ) => Promise; updateEntry?: ( updates: Partial - ) => Promise; + ) => Promise; removeEntry?: () => Promise; } diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index f01a23ce3b..de91a9f66d 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -1158,7 +1158,6 @@ ${rejected createEntry: async (values) => { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); - return label; }, }); }; diff --git a/src/panels/config/script/ha-script-picker.ts b/src/panels/config/script/ha-script-picker.ts index 3265cb158c..1c54655e4d 100644 --- a/src/panels/config/script/ha-script-picker.ts +++ b/src/panels/config/script/ha-script-picker.ts @@ -1214,7 +1214,6 @@ ${rejected createEntry: async (values) => { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); - return label; }, }); }; diff --git a/src/translations/en.json b/src/translations/en.json index 8d055f8420..82c3bd30c8 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -684,15 +684,13 @@ } }, "label-picker": { - "clear": "Clear", - "show_labels": "Show labels", "label": "Label", "labels": "Labels", - "add_label": "Add label", "add_new_sugestion": "Add new label ''{name}''", "add_new": "Add new label…", "no_labels": "You don't have any labels", - "no_match": "No matching labels found" + "no_match": "No matching labels found", + "failed_create_label": "Failed to create label." }, "area-picker": { "clear": "Clear", @@ -2274,6 +2272,7 @@ "name": "Name", "icon": "Icon", "floor": "Floor", + "add_labels": "Add labels", "name_required": "Name is required", "area_id": "Area ID", "unknown_error": "Unknown error",