Improve label picker UI and search (#25522)

This commit is contained in:
Paul Bottein 2025-05-20 12:47:33 +02:00 committed by GitHub
parent a55ef8ad47
commit 4de95f6710
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 198 additions and 238 deletions

View File

@ -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 { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain"; 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 { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
DeviceRegistryEntry, DeviceRegistryEntry,
@ -19,30 +17,19 @@ import {
createLabelRegistryEntry, createLabelRegistryEntry,
subscribeLabelRegistry, subscribeLabelRegistry,
} from "../data/label_registry"; } from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box"; import "./ha-generic-picker";
import type { HaComboBox } from "./ha-combo-box"; import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-combo-box-item"; import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-icon-button"; import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS_ID = "___NO_LABELS___"; const NO_LABELS = "___NO_LABELS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-combo-box-item>
`;
@customElement("ha-label-picker") @customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) { export class HaLabelPicker extends SubscribeMixin(LitElement) {
@ -101,24 +88,13 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[]; @state() private _labels?: LabelRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox; @query("ha-generic-picker") private _picker?: HaGenericPicker;
private _suggestion?: string;
private _init = false;
public async open() { public async open() {
await this.updateComplete; await this.updateComplete;
await this.comboBox?.open(); await this._picker?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
} }
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] { protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@ -129,20 +105,61 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
]; ];
} }
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
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`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
};
private _getLabels = memoizeOne( private _getLabels = memoizeOne(
( (
labels: LabelRegistryEntry[], labels: LabelRegistryEntry[] | undefined,
areas: HomeAssistant["areas"], haAreas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[], haDevices: HomeAssistant["devices"],
entities: EntityRegistryDisplayEntry[], haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"] 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 deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@ -274,7 +291,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
if (areaIds) { if (areaIds) {
areaIds.forEach((areaId) => { areaIds.forEach((areaId) => {
const area = areas[areaId]; const area = haAreas[areaId];
area.labels.forEach((label) => usedLabels.add(label)); area.labels.forEach((label) => usedLabels.add(label));
}); });
} }
@ -291,192 +308,144 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
); );
} }
if (!outputLabels.length) { const items = outputLabels.map<PickerComboBoxItem>((label) => ({
outputLabels = [ id: label.label_id,
{ primary: label.name,
label_id: NO_LABELS_ID, icon: label.icon || undefined,
name: this.hass.localize("ui.components.label-picker.no_match"), icon_path: label.icon ? undefined : mdiLabel,
icon: null, sorting_label: label.name,
color: null, search_labels: [label.name, label.label_id, label.description].filter(
description: null, (v): v is string => Boolean(v)
created_at: 0, ),
modified_at: 0, }));
},
];
}
return noAdd return items;
? 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,
},
];
} }
); );
protected updated(changedProps: PropertyValues) { private _getItems = () =>
if ( this._getLabels(
(!this._init && this.hass && this._labels) || this._labels,
(this._init && changedProps.has("_opened") && this._opened) this.hass.areas,
) { this.hass.devices,
this._init = true; this.hass.entities,
const items = this._getLabels( this.includeDomains,
this._labels!, this.excludeDomains,
this.hass.areas, this.includeDeviceClasses,
Object.values(this.hass.devices), this.deviceFilter,
Object.values(this.hass.entities), this.entityFilter,
this.includeDomains, this.excludeLabels
this.excludeDomains, );
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
this.comboBox.items = items; private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
this.comboBox.filteredItems = items; 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 { protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .autofocus=${this.autofocus}
item-value-path="label_id" .label=${this.label}
item-id-path="label_id" .notFoundLabel=${this.hass.localize(
item-label-path="name" "ui.components.label-picker.no_match"
.value=${this._value} )}
.disabled=${this.disabled} .placeholder=${placeholder}
.required=${this.required} .value=${this.value}
.label=${this.label === undefined && this.hass .getItems=${this._getItems}
? this.hass.localize("ui.components.label-picker.label") .getAdditionalItems=${this._getAdditionalItems}
: this.label} .valueRenderer=${this._valueRenderer}
.placeholder=${this.placeholder @value-changed=${this._valueChanged}
? this._labels?.find((label) => label.label_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private _filterChanged(ev: CustomEvent): void { private _valueChanged(ev: ValueChangedEvent<string>) {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableLabelItem>(
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<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_LABELS_ID) { const value = ev.detail.value;
newValue = "";
this.comboBox.setInputValue(""); if (value === NO_LABELS) {
return; return;
} }
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { if (!value) {
if (newValue !== this._value) { this._setValue(undefined);
this._setValue(newValue);
}
return; 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, { showLabelDetailDialog(this, {
entry: undefined, suggestedName: suggestedName,
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", createEntry: async (values) => {
createEntry: async (values) => { try {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label]; this._setValue(label.label_id);
this.comboBox.filteredItems = this._getLabels( } catch (err: any) {
labels, showAlertDialog(this, {
this.hass.areas!, title: this.hass.localize(
Object.values(this.hass.devices)!, "ui.components.label-picker.failed_create_label"
Object.values(this.hass.entities)!, ),
this.includeDomains, text: err.message,
this.excludeDomains, });
this.includeDeviceClasses, }
this.deviceFilter, },
this.entityFilter, });
this.noAdd, return;
this.excludeLabels }
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
this._suggestion = undefined; this._setValue(value);
this.comboBox.setInputValue("");
} }
private _setValue(value?: string) { private _setValue(value?: string) {

View File

@ -122,6 +122,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
this.hass.locale.language this.hass.locale.language
); );
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length ${labels?.length
? html`<ha-chip-set> ? html`<ha-chip-set>
${repeat( ${repeat(
@ -157,9 +158,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.excludeLabels=${this.value} .excludeLabels=${this.value}
@value-changed=${this._labelChanged} @value-changed=${this._labelChanged}
@ -182,12 +180,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
entry: label, entry: label,
updateEntry: async (values) => { updateEntry: async (values) => {
const updated = await updateLabelRegistryEntry( await updateLabelRegistryEntry(this.hass, label.label_id, values);
this.hass,
label.label_id,
values
);
return updated;
}, },
}); });
} }
@ -219,6 +212,10 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
--ha-input-chip-selected-container-opacity: 0.5; --ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-outline-width: 1px; --md-input-chip-selected-outline-width: 1px;
} }
label {
display: block;
margin: 0 0 8px;
}
`; `;
} }

View File

@ -441,7 +441,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
id="input" id="input"
.type=${"label_id"} .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" "ui.components.target-picker.add_label_id"
)} )}
no-add no-add

View File

@ -142,6 +142,9 @@ class DialogAreaDetail extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this._labels} .value=${this._labels}
@value-changed=${this._labelsChanged} @value-changed=${this._labelsChanged}
.placeholder=${this.hass.localize(
"ui.panel.config.areas.editor.add_labels"
)}
></ha-labels-picker> ></ha-labels-picker>
<ha-picture-upload <ha-picture-upload

View File

@ -1415,7 +1415,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -1110,7 +1110,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -1367,7 +1367,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -1259,7 +1259,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -4,20 +4,17 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert"; 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 "../../../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 type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../resources/styles"; import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { LabelDetailDialogParams } from "./show-dialog-label-detail"; import type { LabelDetailDialogParams } from "./show-dialog-label-detail";
import type {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
} from "../../../data/label_registry";
@customElement("dialog-label-detail") @customElement("dialog-label-detail")
class DialogLabelDetail class DialogLabelDetail
@ -177,7 +174,6 @@ class DialogLabelDetail
private async _updateEntry() { private async _updateEntry() {
this._submitting = true; this._submitting = true;
let newValue: LabelRegistryEntry | undefined;
try { try {
const values: LabelRegistryEntryMutableParams = { const values: LabelRegistryEntryMutableParams = {
name: this._name.trim(), name: this._name.trim(),
@ -186,9 +182,9 @@ class DialogLabelDetail
description: this._description.trim() || null, description: this._description.trim() || null,
}; };
if (this._params!.entry) { if (this._params!.entry) {
newValue = await this._params!.updateEntry!(values); await this._params!.updateEntry!(values);
} else { } else {
newValue = await this._params!.createEntry!(values); await this._params!.createEntry!(values);
} }
this.closeDialog(); this.closeDialog();
} catch (err: any) { } catch (err: any) {
@ -196,7 +192,6 @@ class DialogLabelDetail
} finally { } finally {
this._submitting = false; this._submitting = false;
} }
return newValue;
} }
private async _deleteEntry() { private async _deleteEntry() {

View File

@ -10,10 +10,10 @@ export interface LabelDetailDialogParams {
createEntry?: ( createEntry?: (
values: LabelRegistryEntryMutableParams, values: LabelRegistryEntryMutableParams,
labelId?: string labelId?: string
) => Promise<LabelRegistryEntry>; ) => Promise<unknown>;
updateEntry?: ( updateEntry?: (
updates: Partial<LabelRegistryEntryMutableParams> updates: Partial<LabelRegistryEntryMutableParams>
) => Promise<LabelRegistryEntry>; ) => Promise<unknown>;
removeEntry?: () => Promise<boolean>; removeEntry?: () => Promise<boolean>;
} }

View File

@ -1158,7 +1158,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -1214,7 +1214,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -684,15 +684,13 @@
} }
}, },
"label-picker": { "label-picker": {
"clear": "Clear",
"show_labels": "Show labels",
"label": "Label", "label": "Label",
"labels": "Labels", "labels": "Labels",
"add_label": "Add label",
"add_new_sugestion": "Add new label ''{name}''", "add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…", "add_new": "Add new label…",
"no_labels": "You don't have any labels", "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": { "area-picker": {
"clear": "Clear", "clear": "Clear",
@ -2274,6 +2272,7 @@
"name": "Name", "name": "Name",
"icon": "Icon", "icon": "Icon",
"floor": "Floor", "floor": "Floor",
"add_labels": "Add labels",
"name_required": "Name is required", "name_required": "Name is required",
"area_id": "Area ID", "area_id": "Area ID",
"unknown_error": "Unknown error", "unknown_error": "Unknown error",