mirror of
https://github.com/home-assistant/frontend.git
synced 2025-08-01 05:27:46 +00:00
Improve area picker UI and search
This commit is contained in:
parent
d6ebd9bfc4
commit
0715835e0d
506
src/components/ha-area-combo-box.ts
Normal file
506
src/components/ha-area-combo-box.ts
Normal file
@ -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<AreaComboBoxItem> = (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>`}
|
||||||
|
<span slot="headline">${item.primary}</span>
|
||||||
|
${item.secondary
|
||||||
|
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||||
|
: nothing}
|
||||||
|
</ha-combo-box-item>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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<AreaComboBoxItem>((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`
|
||||||
|
<ha-combo-box
|
||||||
|
item-id-path="id"
|
||||||
|
item-value-path="id"
|
||||||
|
item-label-path="label"
|
||||||
|
.hass=${this.hass}
|
||||||
|
.helper=${this.helper}
|
||||||
|
.value=${this._value}
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
.required=${this.required}
|
||||||
|
.label=${this.label === undefined && this.hass
|
||||||
|
? this.hass.localize("ui.components.area-picker.area")
|
||||||
|
: this.label}
|
||||||
|
.placeholder=${this.placeholder
|
||||||
|
? this.hass.areas[this.placeholder]?.name
|
||||||
|
: undefined}
|
||||||
|
.renderer=${rowRenderer}
|
||||||
|
@filter-changed=${this._filterChanged}
|
||||||
|
@opened-changed=${this._openedChanged}
|
||||||
|
@value-changed=${this._areaChanged}
|
||||||
|
>
|
||||||
|
</ha-combo-box>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean>) {
|
||||||
|
this._opened = ev.detail.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _areaChanged(ev: ValueChangedEvent<string>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,47 +1,24 @@
|
|||||||
import { mdiTextureBox } from "@mdi/js";
|
import { mdiClose, mdiMenuDown, mdiShape, mdiTextureBox } from "@mdi/js";
|
||||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
|
||||||
import type { HassEntity } from "home-assistant-js-websocket";
|
import type { HassEntity } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues, TemplateResult } from "lit";
|
import { LitElement, css, html, nothing, type CSSResultGroup } 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 { fireEvent } from "../common/dom/fire_event";
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
import { computeDomain } from "../common/entity/compute_domain";
|
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||||
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
|
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||||
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
|
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||||
import type { AreaRegistryEntry } from "../data/area_registry";
|
import { getAreaContext } from "../common/entity/context/get_area_context";
|
||||||
import { createAreaRegistryEntry } from "../data/area_registry";
|
import { debounce } from "../common/util/debounce";
|
||||||
import type {
|
import type { HomeAssistant } from "../types";
|
||||||
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 type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
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 "./ha-combo-box";
|
||||||
import type { HaComboBox } from "./ha-combo-box";
|
|
||||||
import "./ha-combo-box-item";
|
import "./ha-combo-box-item";
|
||||||
|
import type { HaComboBoxItem } from "./ha-combo-box-item";
|
||||||
import "./ha-icon-button";
|
import "./ha-icon-button";
|
||||||
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 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 {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@ -99,389 +76,233 @@ export class HaAreaPicker extends LitElement {
|
|||||||
|
|
||||||
@property({ type: Boolean }) public required = false;
|
@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() {
|
private _renderContent() {
|
||||||
await this.updateComplete;
|
const areaId = this.value || "";
|
||||||
await this.comboBox?.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async focus() {
|
if (!areaId) {
|
||||||
await this.updateComplete;
|
return html`
|
||||||
await this.comboBox?.focus();
|
<span slot="headline" class="placeholder"
|
||||||
}
|
>${this.placeholder ??
|
||||||
|
this.hass.localize("ui.components.area-picker.placeholder")}</span
|
||||||
private _getAreas = memoizeOne(
|
>
|
||||||
(
|
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues) {
|
const area = this.hass.areas[areaId];
|
||||||
if (
|
|
||||||
(!this._init && this.hass) ||
|
const showClearIcon =
|
||||||
(this._init && changedProps.has("_opened") && this._opened)
|
!this.required && !this.disabled && !this.hideClearIcon;
|
||||||
) {
|
|
||||||
this._init = true;
|
if (!area) {
|
||||||
const areas = this._getAreas(
|
return html`
|
||||||
Object.values(this.hass.areas),
|
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
|
||||||
Object.values(this.hass.devices),
|
<span slot="headline">${area}</span>
|
||||||
Object.values(this.hass.entities),
|
${showClearIcon
|
||||||
this.includeDomains,
|
? html`<ha-icon-button
|
||||||
this.excludeDomains,
|
class="clear"
|
||||||
this.includeDeviceClasses,
|
slot="end"
|
||||||
this.deviceFilter,
|
@click=${this._clear}
|
||||||
this.entityFilter,
|
.path=${mdiClose}
|
||||||
this.noAdd,
|
></ha-icon-button>`
|
||||||
this.excludeAreas
|
: nothing}
|
||||||
).map((area) => ({
|
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
|
||||||
...area,
|
`;
|
||||||
strings: [area.area_id, ...area.aliases, area.name],
|
|
||||||
}));
|
|
||||||
this.comboBox.items = areas;
|
|
||||||
this.comboBox.filteredItems = areas;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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`
|
return html`
|
||||||
<ha-combo-box
|
${icon
|
||||||
.hass=${this.hass}
|
? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
|
||||||
.helper=${this.helper}
|
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
|
||||||
item-value-path="area_id"
|
<span slot="headline">${areaName}</span>
|
||||||
item-id-path="area_id"
|
${floorName
|
||||||
item-label-path="name"
|
? html`<span slot="supporting-text">${floorName}</span>`
|
||||||
.value=${this._value}
|
: nothing}
|
||||||
.disabled=${this.disabled}
|
${showClearIcon
|
||||||
.required=${this.required}
|
? html`<ha-icon-button
|
||||||
.label=${this.label === undefined && this.hass
|
class="clear"
|
||||||
? this.hass.localize("ui.components.area-picker.area")
|
slot="end"
|
||||||
: this.label}
|
@click=${this._clear}
|
||||||
.placeholder=${this.placeholder
|
.path=${mdiClose}
|
||||||
? this.hass.areas[this.placeholder]?.name
|
></ha-icon-button>`
|
||||||
: undefined}
|
: nothing}
|
||||||
.renderer=${rowRenderer}
|
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
|
||||||
@filter-changed=${this._filterChanged}
|
|
||||||
@opened-changed=${this._openedChanged}
|
|
||||||
@value-changed=${this._areaChanged}
|
|
||||||
>
|
|
||||||
</ha-combo-box>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _filterChanged(ev: CustomEvent): void {
|
protected render() {
|
||||||
const target = ev.target as HaComboBox;
|
return html`
|
||||||
const filterString = ev.detail.value;
|
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||||
if (!filterString) {
|
<div class="container">
|
||||||
this.comboBox.filteredItems = this.comboBox.items;
|
${!this._opened
|
||||||
return;
|
? html`<ha-combo-box-item
|
||||||
}
|
.disabled=${this.disabled}
|
||||||
|
id="anchor"
|
||||||
const filteredItems = fuzzyFilterSort<ScorableAreaRegistryEntry>(
|
type="button"
|
||||||
filterString,
|
compact
|
||||||
target.items?.filter(
|
@click=${this._showPicker}
|
||||||
(item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id)
|
>
|
||||||
) || []
|
${this._renderContent()}
|
||||||
);
|
</ha-combo-box-item>`
|
||||||
if (filteredItems.length === 0) {
|
: html`<ha-area-combo-box
|
||||||
if (!this.noAdd) {
|
id="input"
|
||||||
this.comboBox.filteredItems = [
|
.hass=${this.hass}
|
||||||
{
|
.autofocus=${this.autofocus}
|
||||||
area_id: NO_ITEMS_ID,
|
.label=${this.hass.localize("ui.common.search")}
|
||||||
floor_id: null,
|
.value=${this.value}
|
||||||
name: this.hass.localize("ui.components.area-picker.no_match"),
|
.noAdd=${this.noAdd}
|
||||||
icon: null,
|
.includeDomains=${this.includeDomains}
|
||||||
picture: null,
|
.excludeDomains=${this.excludeDomains}
|
||||||
labels: [],
|
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||||
aliases: [],
|
.entityFilter=${this.entityFilter}
|
||||||
temperature_entity_id: null,
|
.excludeAreas=${this.excludeAreas}
|
||||||
humidity_entity_id: null,
|
hide-clear-icon
|
||||||
created_at: 0,
|
@opened-changed=${this._debounceOpenedChanged}
|
||||||
modified_at: 0,
|
@value-changed=${this._valueChanged}
|
||||||
},
|
@input=${stopPropagation}
|
||||||
] as AreaRegistryEntry[];
|
></ha-area-combo-box>`}
|
||||||
} else {
|
${this._renderHelper()}
|
||||||
this._suggestion = filterString;
|
</div>
|
||||||
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() {
|
private _renderHelper() {
|
||||||
return this.value || "";
|
return this.helper
|
||||||
|
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||||
|
: nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
private _clear(e) {
|
||||||
this._opened = ev.detail.value;
|
e.stopPropagation();
|
||||||
|
this.value = undefined;
|
||||||
|
fireEvent(this, "value-changed", { value: undefined });
|
||||||
|
fireEvent(this, "change");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _areaChanged(ev: ValueChangedEvent<string>) {
|
private _valueChanged(e) {
|
||||||
ev.stopPropagation();
|
e.stopPropagation();
|
||||||
let newValue = ev.detail.value;
|
const value = e.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) {
|
|
||||||
this.value = value;
|
this.value = value;
|
||||||
setTimeout(() => {
|
fireEvent(this, "value-changed", { value });
|
||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "change");
|
||||||
fireEvent(this, "change");
|
}
|
||||||
}, 0);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,6 +58,7 @@ const cardConfigStruct = assign(
|
|||||||
icon_double_tap_action: optional(actionConfigStruct),
|
icon_double_tap_action: optional(actionConfigStruct),
|
||||||
features: optional(array(any())),
|
features: optional(array(any())),
|
||||||
features_position: optional(enums(["bottom", "inline"])),
|
features_position: optional(enums(["bottom", "inline"])),
|
||||||
|
areas: optional(any()),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -82,6 +83,14 @@ export class HuiTileCardEditor
|
|||||||
hideState: boolean
|
hideState: boolean
|
||||||
) =>
|
) =>
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
name: "areas",
|
||||||
|
selector: {
|
||||||
|
area: {
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{ name: "entity", selector: { entity: {} } },
|
{ name: "entity", selector: { entity: {} } },
|
||||||
{
|
{
|
||||||
name: "content",
|
name: "content",
|
||||||
|
@ -702,7 +702,8 @@
|
|||||||
"no_areas": "You don't have any areas",
|
"no_areas": "You don't have any areas",
|
||||||
"no_match": "No matching areas found",
|
"no_match": "No matching areas found",
|
||||||
"unassigned_areas": "Unassigned areas",
|
"unassigned_areas": "Unassigned areas",
|
||||||
"failed_create_area": "Failed to create area."
|
"failed_create_area": "Failed to create area.",
|
||||||
|
"placeholder": "Select an area"
|
||||||
},
|
},
|
||||||
"floor-picker": {
|
"floor-picker": {
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user