Improve area picker UI and search (#25472)

* Improve area picker UI and search

* Feedbacks
This commit is contained in:
Paul Bottein 2025-05-15 15:53:22 +02:00 committed by GitHub
parent 9749a64ae1
commit 20a7b3870c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 147 additions and 220 deletions

View File

@ -403,7 +403,8 @@ export class HaEntityPicker extends LitElement {
} }
public async open() { public async open() {
this._picker?.open(); await this.updateComplete;
await this._picker?.open();
} }
private _valueChanged(ev) { private _valueChanged(ev) {

View File

@ -1,15 +1,14 @@
import { mdiTextureBox } from "@mdi/js"; import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } 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 { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching"; import { getAreaContext } from "../common/entity/context/get_area_context";
import type { AreaRegistryEntry } from "../data/area_registry";
import { createAreaRegistryEntry } from "../data/area_registry"; import { createAreaRegistryEntry } from "../data/area_registry";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
@ -21,26 +20,15 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-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 type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const NO_ITEMS_ID = "___NO_ITEMS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
@customElement("ha-area-picker") @customElement("ha-area-picker")
export class HaAreaPicker extends LitElement { export class HaAreaPicker extends LitElement {
@ -99,41 +87,61 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
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() { private _valueRenderer: PickerValueRenderer = (value) => {
await this.updateComplete; const area = this.hass.areas[value];
await this.comboBox?.focus();
} if (!area) {
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${area}</span>
`;
}
const { floor } = getAreaContext(area, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined;
const icon = area.icon;
return html`
${icon
? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
<span slot="headline">${areaName}</span>
${floorName
? html`<span slot="supporting-text">${floorName}</span>`
: nothing}
`;
};
private _getAreas = memoizeOne( private _getAreas = memoizeOne(
( (
areas: AreaRegistryEntry[], 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"],
excludeAreas: this["excludeAreas"] excludeAreas: this["excludeAreas"]
): AreaRegistryEntry[] => { ): PickerComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
if ( if (
includeDomains || includeDomains ||
excludeDomains || excludeDomains ||
@ -263,203 +271,126 @@ export class HaAreaPicker extends LitElement {
); );
} }
if (!outputAreas.length) { const items = outputAreas.map<PickerComboBoxItem>((area) => {
outputAreas = [ const { floor } = getAreaContext(area, this.hass);
{ const floorName = floor ? computeFloorName(floor) : undefined;
area_id: NO_ITEMS_ID, const areaName = computeAreaName(area);
floor_id: null, return {
name: this.hass.localize("ui.components.area-picker.no_areas"), id: area.area_id,
picture: null, primary: areaName || area.area_id,
icon: null, secondary: floorName,
aliases: [], icon: area.icon || undefined,
labels: [], icon_path: area.icon ? undefined : mdiTextureBox,
temperature_entity_id: null, sorting_label: areaName,
humidity_entity_id: null, search_labels: [
created_at: 0, areaName,
modified_at: 0, floorName,
}, area.area_id,
]; ...area.aliases,
} ].filter((v): v is string => Boolean(v)),
};
});
return noAdd return items;
? outputAreas
: [
...outputAreas,
{
area_id: ADD_NEW_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
icon: "mdi:plus",
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
} }
); );
protected updated(changedProps: PropertyValues) { private _getItems = () =>
if ( this._getAreas(
(!this._init && this.hass) || this.hass.areas,
(this._init && changedProps.has("_opened") && this._opened) this.hass.devices,
) { this.hass.entities,
this._init = true; this.includeDomains,
const areas = this._getAreas( this.excludeDomains,
Object.values(this.hass.areas), this.includeDeviceClasses,
Object.values(this.hass.devices), this.deviceFilter,
Object.values(this.hass.entities), this.entityFilter,
this.includeDomains, this.excludeAreas
this.excludeDomains, );
this.includeDeviceClasses,
this.deviceFilter, private _allAreaNames = memoizeOne(
this.entityFilter, (areas: HomeAssistant["areas"]) =>
this.noAdd, Object.values(areas)
this.excludeAreas .map((area) => computeAreaName(area)?.toLowerCase())
).map((area) => ({ .filter(Boolean) as string[]
...area, );
strings: [area.area_id, ...area.aliases, area.name],
})); private _getAdditionalItems = (
this.comboBox.items = areas; searchString?: string
this.comboBox.filteredItems = areas; ): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
} }
}
const allAreas = this._allAreaNames(this.hass.areas);
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
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="area_id" .label=${this.label}
item-id-path="area_id" .notFoundLabel=${this.hass.localize(
item-label-path="name" "ui.components.area-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.area-picker.area") .getAdditionalItems=${this._getAdditionalItems}
: this.label} .valueRenderer=${this._valueRenderer}
.placeholder=${this.placeholder @value-changed=${this._valueChanged}
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
> >
</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<ScorableAreaRegistryEntry>(
filterString,
target.items?.filter(
(item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
area_id: NO_ITEMS_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
area_id: ADD_NEW_SUGGESTION_ID,
floor_id: null,
name: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value; const value = ev.detail.value;
if (newValue === NO_ITEMS_ID) { if (!value.startsWith(ADD_NEW_ID)) {
newValue = ""; if (value !== this.value) {
this.comboBox.setInputValue(""); this._setValue(value);
return;
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
} }
return; return;
} }
(ev.target as any).value = this._value;
this.hass.loadFragmentTranslation("config"); this.hass.loadFragmentTranslation("config");
const suggestedName = value.substring(ADD_NEW_ID.length);
showAreaRegistryDetailDialog(this, { showAreaRegistryDetailDialog(this, {
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", suggestedName: suggestedName,
createEntry: async (values) => { createEntry: async (values) => {
try { try {
const area = await createAreaRegistryEntry(this.hass, values); const area = await createAreaRegistryEntry(this.hass, values);
const areas = [...Object.values(this.hass.areas), area];
this.comboBox.filteredItems = this._getAreas(
areas,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeAreas
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(area.area_id); this._setValue(area.area_id);
} catch (err: any) { } catch (err: any) {
showAlertDialog(this, { showAlertDialog(this, {
@ -471,17 +402,12 @@ export class HaAreaPicker extends LitElement {
} }
}, },
}); });
this._suggestion = undefined;
this.comboBox.setInputValue("");
} }
private _setValue(value?: string) { private _setValue(value?: string) {
this.value = value; this.value = value;
setTimeout(() => { fireEvent(this, "value-changed", { value });
fireEvent(this, "value-changed", { value }); fireEvent(this, "change");
fireEvent(this, "change");
}, 0);
} }
} }