Improve area floor picker UI and search (#25540)

* Improve area floor picker UI and search

* Improve area floor picker UI and search

* Remove noResultSorting
This commit is contained in:
Paul Bottein 2025-05-23 10:35:47 +02:00 committed by GitHub
parent 67dc830bbf
commit 412a0e9f6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 223 additions and 213 deletions

View File

@ -1,16 +1,16 @@
import { mdiTextureBox } from "@mdi/js"; import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; 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, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
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 { computeFloorName } from "../common/entity/compute_floor_name";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import { computeRTL } from "../common/util/compute_rtl"; import { computeRTL } from "../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../data/area_registry"; import type { AreaRegistryEntry } from "../data/area_registry";
import type { import type {
@ -19,29 +19,33 @@ import type {
} from "../data/device_registry"; } from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import type { FloorRegistryEntry } from "../data/floor_registry"; import {
import { getFloorAreaLookup } from "../data/floor_registry"; getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry";
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-floor-icon"; import "./ha-floor-icon";
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";
import "./ha-tree-indicator"; import "./ha-tree-indicator";
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry; const SEPARATOR = "________";
interface FloorAreaEntry { interface FloorComboBoxItem extends PickerComboBoxItem {
id: string | null; type: "floor" | "area";
name: string; floor?: FloorRegistryEntry;
icon: string | null; area?: AreaRegistryEntry;
strings: string[]; }
interface AreaFloorValue {
id: string;
type: "floor" | "area"; type: "floor" | "area";
level: number | null;
hasFloor?: boolean;
lastArea?: boolean;
} }
@customElement("ha-area-floor-picker") @customElement("ha-area-floor-picker")
@ -50,12 +54,15 @@ export class HaAreaFloorPicker extends LitElement {
@property() public label?: string; @property() public label?: string;
@property() public value?: string; @property({ attribute: false }) public value?: AreaFloorValue;
@property() public helper?: string; @property() public helper?: string;
@property() public placeholder?: string; @property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/** /**
* Show only areas with entities from specific domains. * Show only areas with entities from specific domains.
* @type {Array} * @type {Array}
@ -106,66 +113,53 @@ export class HaAreaFloorPicker 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 _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: string) => {
await this.updateComplete; const item = this._parseValue(value);
await this.comboBox?.focus();
} const area = item.type === "area" && this.hass.areas[value];
if (area) {
const areaName = computeAreaName(area);
return html`
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<slot name="headline">${areaName}</slot>
`;
}
const floor = item.type === "floor" && this.hass.floors[value];
if (floor) {
const floorName = computeFloorName(floor);
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
private _rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) => {
const rtl = computeRTL(this.hass);
return html` return html`
<ha-combo-box-item <ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
type="button" <span slot="headline">${value}</span>
style=${item.type === "area" && item.hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && item.hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.lastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor"
? html`<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon>`
: 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>
`; `;
}; };
private _getAreas = memoizeOne( private _getAreasAndFloors = memoizeOne(
( (
floors: FloorRegistryEntry[], haFloors: HomeAssistant["floors"],
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"],
@ -173,19 +167,11 @@ export class HaAreaFloorPicker extends LitElement {
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"], excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"] excludeFloors: this["excludeFloors"]
): FloorAreaEntry[] => { ): FloorComboBoxItem[] => {
if (!areas.length && !floors.length) { const floors = Object.values(haFloors);
return [ const areas = Object.values(haAreas);
{ const devices = Object.values(haDevices);
id: "no_areas", const entities = Object.values(haEntities);
type: "area",
name: this.hass.localize("ui.components.area-picker.no_areas"),
icon: null,
strings: [],
level: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
@ -326,19 +312,6 @@ export class HaAreaFloorPicker extends LitElement {
); );
} }
if (!outputAreas.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
strings: [],
level: null,
},
];
}
const floorAreaLookup = getFloorAreaLookup(outputAreas); const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter( const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id] (area) => !area.floor_id || !floorAreaLookup[area.floor_id]
@ -360,151 +333,186 @@ export class HaAreaFloorPicker extends LitElement {
return stringCompare(floorA.name, floorB.name); return stringCompare(floorA.name, floorB.name);
}); });
const output: FloorAreaEntry[] = []; const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => { floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) { if (floor) {
output.push({ const floorName = computeFloorName(floor);
id: floor.floor_id,
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return [area.area_id, areaName, ...area.aliases];
})
.flat();
items.push({
id: this._formatValue({ id: floor.floor_id, type: "floor" }),
type: "floor", type: "floor",
name: floor.name, primary: floorName,
icon: floor.icon, floor: floor,
strings: [floor.floor_id, ...floor.aliases, floor.name], search_labels: [
level: floor.level, floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
}); });
} }
output.push( items.push(
...floorAreas.map((area, index, array) => ({ ...floorAreas.map((area) => {
id: area.area_id, const areaName = computeAreaName(area) || area.area_id;
type: "area" as const, return {
name: area.name, id: this._formatValue({ id: area.area_id, type: "area" }),
icon: area.icon, type: "area" as const,
strings: [area.area_id, ...area.aliases, area.name], primary: areaName,
hasFloor: true, area: area,
level: null, icon: area.icon || undefined,
lastArea: index === array.length - 1, search_labels: [area.area_id, areaName, ...area.aliases],
})) };
})
); );
}); });
if (!output.length && !unassisgnedAreas.length) { items.push(
output.push({ ...unassisgnedAreas.map((area) => {
id: "no_areas", const areaName = computeAreaName(area) || area.area_id;
type: "area", return {
name: this.hass.localize( id: this._formatValue({ id: area.area_id, type: "area" }),
"ui.components.area-picker.unassigned_areas" type: "area" as const,
), primary: areaName,
icon: null, icon: area.icon || undefined,
strings: [], search_labels: [area.area_id, areaName, ...area.aliases],
level: null, };
}); })
}
output.push(
...unassisgnedAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
level: null,
}))
); );
return output; return items;
} }
); );
protected updated(changedProps: PropertyValues) { private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
if ( item,
(!this._init && this.hass) || { index },
(this._init && changedProps.has("_opened") && this._opened) combobox
) { ) => {
this._init = true; const nextItem = combobox.filteredItems?.[index + 1];
const areas = this._getAreas( const isLastArea =
Object.values(this.hass.floors), !nextItem ||
Object.values(this.hass.areas), nextItem.type === "floor" ||
Object.values(this.hass.devices), (nextItem.type === "area" && !nextItem.area?.floor_id);
Object.values(this.hass.entities),
this.includeDomains, const rtl = computeRTL(this.hass);
this.excludeDomains,
this.includeDeviceClasses, const hasFloor = item.type === "area" && item.area?.floor_id;
this.deviceFilter,
this.entityFilter, return html`
this.excludeAreas, <ha-combo-box-item
this.excludeFloors type="button"
); style=${item.type === "area" && hasFloor
this.comboBox.items = areas; ? "--md-list-item-leading-space: 48px;"
this.comboBox.filteredItems = areas; : ""}
} >
} ${item.type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${isLastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor" && item.floor
? html`<ha-floor-icon
slot="start"
.floor=${item.floor}
></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`}
${item.primary}
</ha-combo-box-item>
`;
};
private _getItems = () =>
this._getAreasAndFloors(
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
);
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
const [type, id] = value.split(SEPARATOR);
return { id, type: type as "floor" | "area" };
});
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const value = this.value ? this._formatValue(this.value) : undefined;
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="id" .label=${this.label}
item-id-path="id" .searchLabel=${this.searchLabel}
item-label-path="name" .notFoundLabel=${this.hass.localize(
.value=${this._value} "ui.components.area-picker.no_match"
.disabled=${this.disabled} )}
.required=${this.required} .placeholder=${placeholder}
.label=${this.label === undefined && this.hass .value=${value}
? this.hass.localize("ui.components.area-picker.area") .getItems=${this._getItems}
: this.label} .valueRenderer=${this._valueRenderer}
.placeholder=${this.placeholder .rowRenderer=${this._rowRenderer}
? this.hass.areas[this.placeholder]?.name @value-changed=${this._valueChanged}
: undefined}
.renderer=${this._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<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const value = ev.detail.value;
if (newValue === "no_areas") { if (!value) {
this._setValue(undefined);
return; return;
} }
const selected = this.comboBox.selectedItem; const selected = this._parseValue(value);
this._setValue(selected);
}
fireEvent(this, "value-changed", { private _setValue(value?: AreaFloorValue) {
value: { this.value = value;
id: selected.id, fireEvent(this, "value-changed", { value });
type: selected.type, fireEvent(this, "change");
},
});
} }
} }

View File

@ -234,7 +234,7 @@ export class HaPickerComboBox extends LitElement {
const searchString = ev.detail.value.trim() as string; const searchString = ev.detail.value.trim() as string;
const index = this._fuseIndex(this._items); const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, {}, index); const fuse = new HaFuse(this._items, { shouldSort: false }, index);
const results = fuse.multiTermsSearch(searchString); const results = fuse.multiTermsSearch(searchString);
if (results) { if (results) {

View File

@ -398,10 +398,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
id="input" id="input"
.type=${"area_id"} .type=${"area_id"}
.label=${this.hass.localize( .placeholder=${this.hass.localize(
"ui.components.target-picker.add_area_id"
)}
.searchLabel=${this.hass.localize(
"ui.components.target-picker.add_area_id" "ui.components.target-picker.add_area_id"
)} )}
no-add
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter} .entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}