diff --git a/src/components/ha-area-floor-picker.ts b/src/components/ha-area-floor-picker.ts index ed52b1c9ee..1b2f4afb91 100644 --- a/src/components/ha-area-floor-picker.ts +++ b/src/components/ha-area-floor-picker.ts @@ -1,16 +1,16 @@ import { mdiTextureBox } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; 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 { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, query } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; +import { computeAreaName } from "../common/entity/compute_area_name"; import { computeDomain } from "../common/entity/compute_domain"; +import { computeFloorName } from "../common/entity/compute_floor_name"; 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 type { AreaRegistryEntry } from "../data/area_registry"; import type { @@ -19,29 +19,33 @@ import type { } from "../data/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; -import type { FloorRegistryEntry } from "../data/floor_registry"; -import { getFloorAreaLookup } from "../data/floor_registry"; +import { + getFloorAreaLookup, + type FloorRegistryEntry, +} from "../data/floor_registry"; 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-floor-icon"; +import "./ha-generic-picker"; +import type { HaGenericPicker } from "./ha-generic-picker"; 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-tree-indicator"; -type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry; +const SEPARATOR = "________"; -interface FloorAreaEntry { - id: string | null; - name: string; - icon: string | null; - strings: string[]; +interface FloorComboBoxItem extends PickerComboBoxItem { + type: "floor" | "area"; + floor?: FloorRegistryEntry; + area?: AreaRegistryEntry; +} + +interface AreaFloorValue { + id: string; type: "floor" | "area"; - level: number | null; - hasFloor?: boolean; - lastArea?: boolean; } @customElement("ha-area-floor-picker") @@ -50,12 +54,15 @@ export class HaAreaFloorPicker extends LitElement { @property() public label?: string; - @property() public value?: string; + @property({ attribute: false }) public value?: AreaFloorValue; @property() public helper?: string; @property() public placeholder?: string; + @property({ type: String, attribute: "search-label" }) + public searchLabel?: string; + /** * Show only areas with entities from specific domains. * @type {Array} @@ -106,66 +113,53 @@ export class HaAreaFloorPicker extends LitElement { @property({ type: Boolean }) public required = false; - @state() private _opened?: boolean; - - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - private _init = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public async open() { await this.updateComplete; - await this.comboBox?.open(); + await this._picker?.open(); } - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } + private _valueRenderer: PickerValueRenderer = (value: string) => { + const item = this._parseValue(value); + + const area = item.type === "area" && this.hass.areas[value]; + + if (area) { + const areaName = computeAreaName(area); + return html` + ${area.icon + ? html`` + : html``} + ${areaName} + `; + } + + const floor = item.type === "floor" && this.hass.floors[value]; + + if (floor) { + const floorName = computeFloorName(floor); + return html` + + ${floorName} + `; + } - private _rowRenderer: ComboBoxLitRenderer = (item) => { - const rtl = computeRTL(this.hass); return html` - - ${item.type === "area" && item.hasFloor - ? html` - - ` - : nothing} - ${item.type === "floor" - ? html`` - : item.icon - ? html`` - : html``} - ${item.name} - + + ${value} `; }; - private _getAreas = memoizeOne( + private _getAreasAndFloors = memoizeOne( ( - floors: FloorRegistryEntry[], - areas: AreaRegistryEntry[], - devices: DeviceRegistryEntry[], - entities: EntityRegistryDisplayEntry[], + haFloors: HomeAssistant["floors"], + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], @@ -173,19 +167,11 @@ export class HaAreaFloorPicker extends LitElement { entityFilter: this["entityFilter"], excludeAreas: this["excludeAreas"], excludeFloors: this["excludeFloors"] - ): FloorAreaEntry[] => { - if (!areas.length && !floors.length) { - return [ - { - id: "no_areas", - type: "area", - name: this.hass.localize("ui.components.area-picker.no_areas"), - icon: null, - strings: [], - level: null, - }, - ]; - } + ): FloorComboBoxItem[] => { + const floors = Object.values(haFloors); + const areas = Object.values(haAreas); + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); let deviceEntityLookup: DeviceEntityDisplayLookup = {}; 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 unassisgnedAreas = Object.values(outputAreas).filter( (area) => !area.floor_id || !floorAreaLookup[area.floor_id] @@ -360,151 +333,186 @@ export class HaAreaFloorPicker extends LitElement { return stringCompare(floorA.name, floorB.name); }); - const output: FloorAreaEntry[] = []; + const items: FloorComboBoxItem[] = []; floorAreaEntries.forEach(([floor, floorAreas]) => { if (floor) { - output.push({ - id: floor.floor_id, + const floorName = computeFloorName(floor); + + 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", - name: floor.name, - icon: floor.icon, - strings: [floor.floor_id, ...floor.aliases, floor.name], - level: floor.level, + primary: floorName, + floor: floor, + search_labels: [ + floor.floor_id, + floorName, + ...floor.aliases, + ...areaSearchLabels, + ], }); } - output.push( - ...floorAreas.map((area, index, array) => ({ - id: area.area_id, - type: "area" as const, - name: area.name, - icon: area.icon, - strings: [area.area_id, ...area.aliases, area.name], - hasFloor: true, - level: null, - lastArea: index === array.length - 1, - })) + items.push( + ...floorAreas.map((area) => { + const areaName = computeAreaName(area) || area.area_id; + return { + id: this._formatValue({ id: area.area_id, type: "area" }), + type: "area" as const, + primary: areaName, + area: area, + icon: area.icon || undefined, + search_labels: [area.area_id, areaName, ...area.aliases], + }; + }) ); }); - if (!output.length && !unassisgnedAreas.length) { - output.push({ - id: "no_areas", - type: "area", - name: this.hass.localize( - "ui.components.area-picker.unassigned_areas" - ), - icon: null, - strings: [], - 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, - })) + items.push( + ...unassisgnedAreas.map((area) => { + const areaName = computeAreaName(area) || area.area_id; + return { + id: this._formatValue({ id: area.area_id, type: "area" }), + type: "area" as const, + primary: areaName, + icon: area.icon || undefined, + search_labels: [area.area_id, areaName, ...area.aliases], + }; + }) ); - return output; + return items; } ); - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const areas = this._getAreas( - Object.values(this.hass.floors), - Object.values(this.hass.areas), - Object.values(this.hass.devices), - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.excludeAreas, - this.excludeFloors - ); - this.comboBox.items = areas; - this.comboBox.filteredItems = areas; - } - } + private _rowRenderer: ComboBoxLitRenderer = ( + item, + { index }, + combobox + ) => { + const nextItem = combobox.filteredItems?.[index + 1]; + const isLastArea = + !nextItem || + nextItem.type === "floor" || + (nextItem.type === "area" && !nextItem.area?.floor_id); + + const rtl = computeRTL(this.hass); + + const hasFloor = item.type === "area" && item.area?.floor_id; + + return html` + + ${item.type === "area" && hasFloor + ? html` + + ` + : nothing} + ${item.type === "floor" && item.floor + ? html`` + : item.icon + ? html`` + : html``} + ${item.primary} + + `; + }; + + 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 { + const placeholder = + this.placeholder ?? this.hass.localize("ui.components.area-picker.area"); + + const value = this.value ? this._formatValue(this.value) : undefined; + return html` - - + `; } - 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 || [] - ); - - this.comboBox.filteredItems = filteredItems; - } - - private get _value() { - return this.value || ""; - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private async _areaChanged(ev: ValueChangedEvent) { + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); - const newValue = ev.detail.value; + const value = ev.detail.value; - if (newValue === "no_areas") { + if (!value) { + this._setValue(undefined); return; } - const selected = this.comboBox.selectedItem; + const selected = this._parseValue(value); + this._setValue(selected); + } - fireEvent(this, "value-changed", { - value: { - id: selected.id, - type: selected.type, - }, - }); + private _setValue(value?: AreaFloorValue) { + this.value = value; + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); } } diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index bd306806a0..5280909ef8 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -234,7 +234,7 @@ export class HaPickerComboBox extends LitElement { const searchString = ev.detail.value.trim() as string; 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); if (results) { diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index e7a98128b7..01624644ae 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -398,10 +398,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} id="input" .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" )} - no-add .deviceFilter=${this.deviceFilter} .entityFilter=${this.entityFilter} .includeDeviceClasses=${this.includeDeviceClasses}