diff --git a/src/components/ha-area-combo-box.ts b/src/components/ha-area-combo-box.ts new file mode 100644 index 0000000000..2d969feb6e --- /dev/null +++ b/src/components/ha-area-combo-box.ts @@ -0,0 +1,506 @@ +import { mdiTextureBox } from "@mdi/js"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import Fuse from "fuse.js"; +import type { HassEntity } from "home-assistant-js-websocket"; +import type { PropertyValues, TemplateResult } from "lit"; +import { LitElement, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +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 { getAreaContext } from "../common/entity/context/get_area_context"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; +import type { AreaRegistryEntry } from "../data/area_registry"; +import { createAreaRegistryEntry } from "../data/area_registry"; +import type { + DeviceEntityDisplayLookup, + DeviceRegistryEntry, +} from "../data/device_registry"; +import { getDeviceEntityDisplayLookup } from "../data/device_registry"; +import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; +import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; +import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; +import { HaFuse } from "../resources/fuse"; +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-svg-icon"; + +interface AreaComboBoxItem { + // Force empty label to always display empty value by default in the search field + id: string; + label: ""; + primary: string; + secondary?: string; + icon?: string; + search_labels?: string[]; + sorting_label?: string; +} + +const rowRenderer: ComboBoxLitRenderer = (item) => html` + + ${item.icon + ? html`` + : html``} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + +`; + +const ADD_NEW_ID = "___ADD_NEW___"; +const NO_ITEMS_ID = "___NO_ITEMS___"; +const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___"; + +@customElement("ha-area-combo-box") +export class HaAreaComboBox extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ type: Boolean, attribute: "no-add" }) + public noAdd = false; + + /** + * Show only areas with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no areas with entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + + /** + * Show only areas with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + /** + * List of areas to be excluded. + * @type {Array} + * @attr exclude-areas + */ + @property({ type: Array, attribute: "exclude-areas" }) + public excludeAreas?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _opened?: boolean; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + private _suggestion?: string; + + private _init = false; + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + private _getItems = memoizeOne( + ( + areas: AreaRegistryEntry[], + devices: DeviceRegistryEntry[], + entities: EntityRegistryDisplayEntry[], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + includeDeviceClasses: this["includeDeviceClasses"], + deviceFilter: this["deviceFilter"], + entityFilter: this["entityFilter"], + noAdd: this["noAdd"], + excludeAreas: this["excludeAreas"] + ): AreaComboBoxItem[] => { + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; + let inputDevices: DeviceRegistryEntry[] | undefined; + let inputEntities: EntityRegistryDisplayEntry[] | undefined; + + if ( + includeDomains || + excludeDomains || + includeDeviceClasses || + deviceFilter || + entityFilter + ) { + deviceEntityLookup = getDeviceEntityDisplayLookup(entities); + inputDevices = devices; + inputEntities = entities.filter((entity) => entity.area_id); + + if (includeDomains) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + inputEntities = inputEntities!.filter((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + } + + if (excludeDomains) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return true; + } + return entities.every( + (entity) => + !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + inputEntities = inputEntities!.filter( + (entity) => + !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + } + + if (includeDeviceClasses) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + } + + if (deviceFilter) { + inputDevices = inputDevices!.filter((device) => + deviceFilter!(device) + ); + } + + if (entityFilter) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter(stateObj); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter!(stateObj); + }); + } + } + + let outputAreas = areas; + + let areaIds: string[] | undefined; + + if (inputDevices) { + areaIds = inputDevices + .filter((device) => device.area_id) + .map((device) => device.area_id!); + } + + if (inputEntities) { + areaIds = (areaIds ?? []).concat( + inputEntities + .filter((entity) => entity.area_id) + .map((entity) => entity.area_id!) + ); + } + + if (areaIds) { + outputAreas = outputAreas.filter((area) => + areaIds!.includes(area.area_id) + ); + } + + if (excludeAreas) { + outputAreas = outputAreas.filter( + (area) => !excludeAreas!.includes(area.area_id) + ); + } + + let items = outputAreas + .map((area) => { + const { floor } = getAreaContext(area, this.hass); + const floorName = floor ? computeFloorName(floor) : undefined; + const areaName = computeAreaName(area); + return { + label: "", + id: area.area_id, + primary: areaName || area.area_id, + secondary: floorName, + icon: area.icon || undefined, + sorting_label: areaName, + search_labels: [ + areaName, + floorName, + area.area_id, + ...area.aliases, + ].filter((v): v is string => Boolean(v)), + }; + }) + .sort((entityA, entityB) => + caseInsensitiveStringCompare( + entityA.sorting_label!, + entityB.sorting_label!, + this.hass.locale.language + ) + ); + + if (!items.length) { + items = [ + { + label: "", + id: NO_ITEMS_ID, + primary: this.hass.localize("ui.components.area-picker.no_areas"), + icon: undefined, + }, + ]; + } + + return noAdd + ? items + : [ + ...items, + { + label: "", + id: ADD_NEW_ID, + primary: this.hass.localize("ui.components.area-picker.add_new"), + icon: "mdi:plus", + }, + ]; + } + ); + + protected updated(changedProps: PropertyValues) { + if ( + (!this._init && this.hass) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const items = this._getItems( + 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.noAdd, + this.excludeAreas + ); + this.comboBox.items = items; + this.comboBox.filteredItems = items; + } + } + + protected render(): TemplateResult { + return html` + + + `; + } + + private _fuseIndex = memoizeOne((items: AreaComboBoxItem[]) => + Fuse.createIndex(["search_labels"], items) + ); + + private _filterChanged(ev: CustomEvent): void { + if (!this._opened) return; + + const target = ev.target as HaComboBox; + const items = target.items as AreaComboBoxItem[]; + const filterString = ev.detail.value.trim().toLowerCase() as string; + + const index = this._fuseIndex(items); + const fuse = new HaFuse(items, {}, index); + + const results = fuse.multiTermsSearch(filterString); + + if (results) { + if (results.length === 0) { + if (!this.noAdd) { + this.comboBox.filteredItems = [ + { + id: NO_ITEMS_ID, + primary: this.hass.localize("ui.components.area-picker.no_match"), + icon: "mdi:search", + }, + ] as AreaComboBoxItem[]; + } else { + this._suggestion = filterString; + this.comboBox.filteredItems = [ + { + id: ADD_NEW_SUGGESTION_ID, + primary: this.hass.localize( + "ui.components.area-picker.add_new_sugestion", + { name: this._suggestion } + ), + icon: "mdi:plus", + }, + ] as AreaComboBoxItem[]; + } + } + } else { + this.comboBox.filteredItems = target.items as AreaComboBoxItem[]; + } + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _areaChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === NO_ITEMS_ID) { + newValue = ""; + this.comboBox.setInputValue(""); + return; + } + + if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { + if (newValue !== this._value) { + this._setValue(newValue); + } + return; + } + + (ev.target as any).value = this._value; + + this.hass.loadFragmentTranslation("config"); + + showAreaRegistryDetailDialog(this, { + suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", + createEntry: async (values) => { + try { + const area = await createAreaRegistryEntry(this.hass, values); + const areas = [...Object.values(this.hass.areas), area]; + this.comboBox.filteredItems = this._getItems( + 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); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.area-picker.failed_create_area" + ), + text: err.message, + }); + } + }, + }); + + this._suggestion = undefined; + this.comboBox.setInputValue(""); + } + + private _setValue(value?: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + }, 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-area-combo-box": HaAreaComboBox; + } +} diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 5a77caf84a..37e77f7ed4 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -1,47 +1,24 @@ -import { mdiTextureBox } from "@mdi/js"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { mdiClose, mdiMenuDown, mdiShape, mdiTextureBox } from "@mdi/js"; +import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, html } from "lit"; +import { LitElement, css, html, nothing, type CSSResultGroup } 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 { AreaRegistryEntry } from "../data/area_registry"; -import { createAreaRegistryEntry } from "../data/area_registry"; -import type { - DeviceEntityDisplayLookup, - DeviceRegistryEntry, -} from "../data/device_registry"; -import { getDeviceEntityDisplayLookup } from "../data/device_registry"; -import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; -import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; -import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; -import type { HomeAssistant, ValueChangedEvent } from "../types"; +import { stopPropagation } from "../common/dom/stop_propagation"; +import { computeAreaName } from "../common/entity/compute_area_name"; +import { computeFloorName } from "../common/entity/compute_floor_name"; +import { getAreaContext } from "../common/entity/context/get_area_context"; +import { debounce } from "../common/util/debounce"; +import type { HomeAssistant } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-area-combo-box"; +import type { HaAreaComboBox } from "./ha-area-combo-box"; import "./ha-combo-box"; -import type { HaComboBox } from "./ha-combo-box"; import "./ha-combo-box-item"; +import type { HaComboBoxItem } from "./ha-combo-box-item"; import "./ha-icon-button"; import "./ha-svg-icon"; -type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry; - -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - ${item.icon - ? html`` - : html``} - ${item.name} - -`; - -const ADD_NEW_ID = "___ADD_NEW___"; -const NO_ITEMS_ID = "___NO_ITEMS___"; -const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___"; - @customElement("ha-area-picker") export class HaAreaPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -99,389 +76,233 @@ export class HaAreaPicker extends LitElement { @property({ type: Boolean }) public required = false; - @state() private _opened?: boolean; + @property({ attribute: "hide-clear-icon", type: Boolean }) + public hideClearIcon = false; - @query("ha-combo-box", true) public comboBox!: HaComboBox; + @query("#anchor") private _anchor?: HaComboBoxItem; - private _suggestion?: string; + @query("#input") private _input?: HaAreaComboBox; - private _init = false; + @state() private _opened = false; - public async open() { - await this.updateComplete; - await this.comboBox?.open(); - } + private _renderContent() { + const areaId = this.value || ""; - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } - - private _getAreas = memoizeOne( - ( - areas: AreaRegistryEntry[], - devices: DeviceRegistryEntry[], - entities: EntityRegistryDisplayEntry[], - includeDomains: this["includeDomains"], - excludeDomains: this["excludeDomains"], - includeDeviceClasses: this["includeDeviceClasses"], - deviceFilter: this["deviceFilter"], - entityFilter: this["entityFilter"], - noAdd: this["noAdd"], - excludeAreas: this["excludeAreas"] - ): AreaRegistryEntry[] => { - let deviceEntityLookup: DeviceEntityDisplayLookup = {}; - let inputDevices: DeviceRegistryEntry[] | undefined; - let inputEntities: EntityRegistryDisplayEntry[] | undefined; - - if ( - includeDomains || - excludeDomains || - includeDeviceClasses || - deviceFilter || - entityFilter - ) { - deviceEntityLookup = getDeviceEntityDisplayLookup(entities); - inputDevices = devices; - inputEntities = entities.filter((entity) => entity.area_id); - - if (includeDomains) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - inputEntities = inputEntities!.filter((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - } - - if (excludeDomains) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return true; - } - return entities.every( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - inputEntities = inputEntities!.filter( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - } - - if (includeDeviceClasses) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - }); - inputEntities = inputEntities!.filter((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - } - - if (deviceFilter) { - inputDevices = inputDevices!.filter((device) => - deviceFilter!(device) - ); - } - - if (entityFilter) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter(stateObj); - }); - }); - inputEntities = inputEntities!.filter((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter!(stateObj); - }); - } - } - - let outputAreas = areas; - - let areaIds: string[] | undefined; - - if (inputDevices) { - areaIds = inputDevices - .filter((device) => device.area_id) - .map((device) => device.area_id!); - } - - if (inputEntities) { - areaIds = (areaIds ?? []).concat( - inputEntities - .filter((entity) => entity.area_id) - .map((entity) => entity.area_id!) - ); - } - - if (areaIds) { - outputAreas = outputAreas.filter((area) => - areaIds!.includes(area.area_id) - ); - } - - if (excludeAreas) { - outputAreas = outputAreas.filter( - (area) => !excludeAreas!.includes(area.area_id) - ); - } - - if (!outputAreas.length) { - outputAreas = [ - { - area_id: NO_ITEMS_ID, - floor_id: null, - name: this.hass.localize("ui.components.area-picker.no_areas"), - picture: null, - icon: null, - aliases: [], - labels: [], - temperature_entity_id: null, - humidity_entity_id: null, - created_at: 0, - modified_at: 0, - }, - ]; - } - - return noAdd - ? 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, - }, - ]; + if (!areaId) { + return html` + ${this.placeholder ?? + this.hass.localize("ui.components.area-picker.placeholder")} + + `; } - ); - 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.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 - ).map((area) => ({ - ...area, - strings: [area.area_id, ...area.aliases, area.name], - })); - this.comboBox.items = areas; - this.comboBox.filteredItems = areas; + const area = this.hass.areas[areaId]; + + const showClearIcon = + !this.required && !this.disabled && !this.hideClearIcon; + + if (!area) { + return html` + + ${area} + ${showClearIcon + ? html`` + : nothing} + + `; } - } - protected render(): TemplateResult { + 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`` + : html``} + ${areaName} + ${floorName + ? html`${floorName}` + : nothing} + ${showClearIcon + ? html`` + : nothing} + `; } - 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_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; - } + protected render() { + return html` + ${this.label ? html`` : nothing} +
+ ${!this._opened + ? html` + ${this._renderContent()} + ` + : html``} + ${this._renderHelper()} +
+ `; } - private get _value() { - return this.value || ""; + private _renderHelper() { + return this.helper + ? html`${this.helper}` + : nothing; } - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; + private _clear(e) { + e.stopPropagation(); + this.value = undefined; + fireEvent(this, "value-changed", { value: undefined }); + fireEvent(this, "change"); } - private _areaChanged(ev: ValueChangedEvent) { - ev.stopPropagation(); - let newValue = ev.detail.value; - - if (newValue === NO_ITEMS_ID) { - newValue = ""; - this.comboBox.setInputValue(""); - return; - } - - if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { - if (newValue !== this._value) { - this._setValue(newValue); - } - return; - } - - (ev.target as any).value = this._value; - - this.hass.loadFragmentTranslation("config"); - - showAreaRegistryDetailDialog(this, { - suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", - createEntry: async (values) => { - try { - 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); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.components.area-picker.failed_create_area" - ), - text: err.message, - }); - } - }, - }); - - this._suggestion = undefined; - this.comboBox.setInputValue(""); - } - - private _setValue(value?: string) { + private _valueChanged(e) { + e.stopPropagation(); + const value = e.detail.value; this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + } + + private async _showPicker() { + if (this.disabled) { + return; + } + this._opened = true; + await this.updateComplete; + this._input?.focus(); + this._input?.open(); + } + + // Multiple calls to _openedChanged can be triggered in quick succession + // when the menu is opened + private _debounceOpenedChanged = debounce( + (ev) => this._openedChanged(ev), + 10 + ); + + private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { + const opened = ev.detail.value; + if (this._opened && !opened) { + this._opened = false; + await this.updateComplete; + this._anchor?.focus(); + } + } + + static get styles(): CSSResultGroup { + return [ + css` + mwc-menu-surface { + --mdc-menu-min-width: 100%; + } + .container { + position: relative; + display: block; + } + ha-combo-box-item { + background-color: var(--mdc-text-field-fill-color, whitesmoke); + border-radius: 4px; + border-end-end-radius: 0; + border-end-start-radius: 0; + --md-list-item-one-line-container-height: 56px; + --md-list-item-two-line-container-height: 56px; + --md-list-item-top-space: 8px; + --md-list-item-bottom-space: 8px; + --md-list-item-leading-space: 8px; + --md-list-item-trailing-space: 8px; + --ha-md-list-item-gap: 8px; + /* Remove the default focus ring */ + --md-focus-ring-width: 0px; + --md-focus-ring-duration: 0s; + } + + /* Add Similar focus style as the text field */ + ha-combo-box-item:after { + display: block; + content: ""; + position: absolute; + pointer-events: none; + bottom: 0; + left: 0; + right: 0; + height: 1px; + width: 100%; + background-color: var( + --mdc-text-field-idle-line-color, + rgba(0, 0, 0, 0.42) + ); + transform: + height 180ms ease-in-out, + background-color 180ms ease-in-out; + } + + ha-combo-box-item:focus:after { + height: 2px; + background-color: var(--mdc-theme-primary); + } + + ha-combo-box-item ha-svg-icon[slot="start"] { + margin: 0 4px; + } + .clear { + margin: 0 -8px; + --mdc-icon-button-size: 32px; + --mdc-icon-size: 20px; + } + .edit { + --mdc-icon-size: 20px; + width: 32px; + } + label { + display: block; + margin: 0 0 8px; + } + .placeholder { + color: var(--secondary-text-color); + padding: 0 8px; + } + `, + ]; } } diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index 3fe5cc938f..cd599f79be 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -58,6 +58,7 @@ const cardConfigStruct = assign( icon_double_tap_action: optional(actionConfigStruct), features: optional(array(any())), features_position: optional(enums(["bottom", "inline"])), + areas: optional(any()), }) ); @@ -82,6 +83,14 @@ export class HuiTileCardEditor hideState: boolean ) => [ + { + name: "areas", + selector: { + area: { + multiple: true, + }, + }, + }, { name: "entity", selector: { entity: {} } }, { name: "content", diff --git a/src/translations/en.json b/src/translations/en.json index 5c4a557729..4eb36cfb3f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -702,7 +702,8 @@ "no_areas": "You don't have any areas", "no_match": "No matching areas found", "unassigned_areas": "Unassigned areas", - "failed_create_area": "Failed to create area." + "failed_create_area": "Failed to create area.", + "placeholder": "Select an area" }, "floor-picker": { "clear": "Clear",