Compare commits

..

18 Commits

Author SHA1 Message Date
Wendelin
4f31761f06 Add entities 2025-09-26 16:55:44 +02:00
Wendelin
eabe7e5492 WIP target picker popover 2025-09-25 17:09:43 +02:00
Wendelin
b443ebfb0c Merge branch 'dev' of github.com:home-assistant/frontend into target-selector 2025-09-25 11:34:46 +02:00
Wendelin
32e4c23c1a Merge branch 'dev' of github.com:home-assistant/frontend into target-selected-value 2025-09-25 09:17:09 +02:00
Wendelin
cf97566669 Fix dialog title 2025-09-18 16:59:37 +02:00
Wendelin
c68d73b1ef Add overview dialog 2025-09-18 11:08:52 +02:00
Wendelin
89ef753309 Fix filtering 2025-09-17 15:18:49 +02:00
Wendelin
b149ddc3d4 Enhance target picker with filtering options for devices and entities 2025-09-16 17:31:46 +02:00
Wendelin
a2e99de828 Do not show entities count for sub entries 2025-09-16 15:51:18 +02:00
Wendelin
e3655feda0 Keep chips in history and logbook 2025-09-16 13:14:11 +02:00
Wendelin
a5be92c277 Merge branch 'dev' of github.com:home-assistant/frontend into target-selected-value 2025-09-16 11:08:44 +02:00
Wendelin
043849b057 Group floor and area, add label icon, remove remove group 2025-09-12 14:33:41 +02:00
Wendelin
9a69566000 Fix sublist 2025-09-12 12:02:01 +02:00
Wendelin
9cdb57476a Merge branch 'dev' of github.com:home-assistant/frontend into target-selected-value 2025-09-11 15:59:07 +02:00
Wendelin
f8d90d003e fix entity domain name 2025-09-11 14:36:44 +02:00
Wendelin
f53ee52b0e Fix typo 2025-09-11 12:27:05 +02:00
Wendelin
f8cc1531e5 Use extractFromTarget 2025-09-11 12:25:02 +02:00
Wendelin
11f65ef0f7 Add new target selected value view 2025-09-09 15:38:48 +02:00
38 changed files with 3261 additions and 1607 deletions

View File

@@ -8,10 +8,10 @@ interface AreaContext {
}
export const getAreaContext = (
area: AreaRegistryEntry,
hass: HomeAssistant
hassFloors: HomeAssistant["floors"]
): AreaContext => {
const floorId = area.floor_id;
const floor = floorId ? hass.floors[floorId] : undefined;
const floor = floorId ? hassFloors[floorId] : undefined;
return {
area: area,

View File

@@ -7,7 +7,7 @@ import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-sortable";
import "./ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {

View File

@@ -1,4 +1,3 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -8,8 +7,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
interface AttributeOption {
value: string;
label: string;

View File

@@ -1,14 +1,16 @@
import { mdiPlus, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import {
getEntities,
type EntityComboBoxItem,
} from "../../data/entity_registry";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
@@ -19,21 +21,11 @@ import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon";
import "./state-badge";
interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
@@ -250,7 +242,7 @@ export class HaEntityPicker extends LitElement {
);
private _getItems = () =>
this._getEntities(
getEntities(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -258,125 +250,10 @@ export class HaEntityPicker extends LitElement {
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities
this.excludeEntities,
this.value
);
private _getEntities = memoizeOne(
(
hass: this["hass"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states);
if (includeEntities) {
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
const isRTL = computeRTL(this.hass);
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
id: entityId,
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj,
};
});
if (includeDeviceClasses) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurement) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
);
}
return items;
}
);
protected render() {
const placeholder =
this.placeholder ??

View File

@@ -1,4 +1,3 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -9,8 +8,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
interface StateOption {
value: string;
label: string;

View File

@@ -8,21 +8,13 @@ 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 { computeRTL } from "../common/util/compute_rtl";
import type { AreaRegistryEntry } 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 {
getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry";
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
@@ -30,24 +22,12 @@ 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";
const SEPARATOR = "________";
interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
floor?: FloorRegistryEntry;
area?: AreaRegistryEntry;
}
interface AreaFloorValue {
id: string;
type: "floor" | "area";
}
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -154,243 +134,6 @@ export class HaAreaFloorPicker extends LitElement {
`;
};
private _getAreasAndFloors = memoizeOne(
(
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"]
): 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;
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 (excludeFloors) {
outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
);
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
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",
primary: floorName,
floor: floor,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
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],
};
})
);
});
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 items;
}
);
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
@@ -446,11 +189,13 @@ export class HaAreaFloorPicker extends LitElement {
};
private _getItems = () =>
this._getAreasAndFloors(
getAreasAndFloors(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._formatValue,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,

View File

@@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement {
);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this.hass!);
const { floor } = getAreaContext(area, this.hass.floors);
return {
value: area.area_id,
label: area.name,

View File

@@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass!);
const { floor } = getAreaContext(area, this.hass.floors);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {

View File

@@ -1,34 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-dialog-footer")
export class HaDialogFooter extends LitElement {
protected render() {
return html`
<footer class="footer">
<slot name="secondaryAction"></slot>
<slot name="primaryAction"></slot>
</footer>
`;
}
static get styles() {
return [
css`
.footer {
display: flex;
gap: 12px;
justify-content: flex-end;
align-items: center;
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-footer": HaDialogFooter;
}
}

View File

@@ -1,44 +1,12 @@
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { customElement } from "lit/decorators";
@customElement("ha-dialog-header")
export class HaDialogHeader extends LitElement {
@state()
private _hasSubtitle = false;
protected firstUpdated() {
this._checkSubtitleContent();
}
private _checkSubtitleContent() {
const subtitleSlot = this.shadowRoot?.querySelector(
'slot[name="subtitle"]'
) as HTMLSlotElement;
if (subtitleSlot) {
const assignedNodes = subtitleSlot.assignedNodes({ flatten: true });
this._hasSubtitle = assignedNodes.some((node) => {
let text: string | any[] | undefined;
if (node.nodeType === Node.TEXT_NODE) {
text = node.textContent?.trim();
}
if (node.nodeType === Node.ELEMENT_NODE) {
text = (node as Element).textContent?.trim();
}
return text && text.length > 0;
});
}
}
protected render() {
return html`
<header class="header">
<div
class=${classMap({
"header-bar": true,
"no-subtitle": !this._hasSubtitle,
})}
>
<div class="header-bar">
<section class="header-navigation-icon">
<slot name="navigationIcon"></slot>
</section>
@@ -47,10 +15,7 @@ export class HaDialogHeader extends LitElement {
<slot name="title"></slot>
</div>
<div class="header-subtitle">
<slot
name="subtitle"
@slotchange=${this._checkSubtitleContent}
></slot>
<slot name="subtitle"></slot>
</div>
</section>
<section class="header-action-items">
@@ -79,9 +44,6 @@ export class HaDialogHeader extends LitElement {
padding: 4px;
box-sizing: border-box;
}
.header-bar.no-subtitle {
align-items: center;
}
.header-content {
flex: 1;
padding: 10px 4px;

View File

@@ -100,8 +100,6 @@ export class HaDialog extends DialogBase {
}
.mdc-dialog__container {
align-items: var(--vertical-align-dialog, center);
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
}
.mdc-dialog__title {
padding: 16px 16px 0 16px;
@@ -123,11 +121,7 @@ export class HaDialog extends DialogBase {
position: var(--dialog-surface-position, relative);
top: var(--dialog-surface-top);
margin-top: var(--dialog-surface-margin-top);
min-width: calc(
var(--mdc-dialog-min-width, 100vw) - var(
--safe-area-inset-left
) - var(--safe-area-inset-right)
);
min-width: var(--mdc-dialog-min-width, 100vw);
min-height: var(--mdc-dialog-min-height, auto);
border-radius: var(--ha-dialog-border-radius, 24px);
-webkit-backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
@@ -144,18 +138,12 @@ export class HaDialog extends DialogBase {
@media all and (max-width: 450px), all and (max-height: 500px) {
.mdc-dialog .mdc-dialog__surface {
min-height: calc(
100vh - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
max-height: calc(
100vh - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
min-height: 100vh;
max-height: 100vh;
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
}

View File

@@ -49,6 +49,7 @@ export class HaExpansionPanel extends LitElement {
tabindex=${this.noCollapse ? -1 : 0}
aria-expanded=${this.expanded}
aria-controls="sect1"
part="summary"
>
${this.leftChevron ? chevronIcon : nothing}
<slot name="leading-icon"></slot>

View File

@@ -79,6 +79,7 @@ export class HaGenericPicker extends LitElement {
${!this._opened
? html`
<ha-picker-field
id="picker"
type="button"
compact
aria-label=${ifDefined(this.label)}

View File

@@ -1,10 +1,10 @@
import { Dialog } from "@material/web/dialog/internal/dialog";
import { styles } from "@material/web/dialog/internal/dialog-styles";
import {
type DialogAnimation,
DIALOG_DEFAULT_CLOSE_ANIMATION,
DIALOG_DEFAULT_OPEN_ANIMATION,
} from "@material/web/dialog/internal/animations";
import { Dialog } from "@material/web/dialog/internal/dialog";
import { styles } from "@material/web/dialog/internal/dialog-styles";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
@@ -57,6 +57,9 @@ export class HaMdDialog extends Dialog {
@property({ attribute: "disable-cancel-action", type: Boolean })
public disableCancelAction = false;
@property({ attribute: "flexcontent", type: Boolean, reflect: true })
public flexContent = false;
private _polyfillDialogRegistered = false;
constructor() {
@@ -200,6 +203,10 @@ export class HaMdDialog extends Dialog {
.scrim {
z-index: 10; /* overlay navigation */
}
:host([flexcontent]) .content {
display: flex;
}
`,
];
}

View File

@@ -33,7 +33,7 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
export const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item type="button" compact>

File diff suppressed because it is too large Load Diff

View File

@@ -1,304 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import type { HomeAssistant } from "../types";
import "./ha-dialog-header";
import "./ha-icon-button";
export type DialogSize = "small" | "medium" | "large" | "full";
export type DialogSizeOnTitleClick = DialogSize | "none";
@customElement("ha-wa-dialog")
export class HaWaDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public open = false;
@property({ type: String, reflect: true, attribute: "dialog-size" })
public dialogSize: DialogSize = "medium";
@property({ type: String, reflect: true, attribute: "current-dialog-size" })
public currentDialogSize: DialogSize = this.dialogSize;
@property({
type: String,
reflect: true,
attribute: "dialog-size-on-title-click",
})
public dialogSizeOnTitleClick: DialogSizeOnTitleClick = "none";
@property({ type: Boolean, reflect: true, attribute: "scrim-dismissable" })
public scrimDismissable = false;
@property({ type: String, attribute: "header-title" })
public headerTitle = "";
@property({ type: String, attribute: "header-subtitle" })
public headerSubtitle = "";
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@state()
private _open = false;
protected updated(
changedProperties: Map<string | number | symbol, unknown>
): void {
super.updated(changedProperties);
if (changedProperties.has("open")) {
if (this.open) {
this._open = this.open;
}
}
if (changedProperties.has("dialogSize")) {
this.currentDialogSize = this.dialogSize;
}
}
protected render() {
return html`
<wa-dialog
.open=${this._open}
.lightDismiss=${this.scrimDismissable}
without-header
@wa-show=${this._handleShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="heading">
<ha-dialog-header>
<slot name="navigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
<slot name="title" slot="title">
<span @click=${this.toggleSize} class="title">
${this.headerTitle}
</span>
</slot>
<slot name="subtitle" slot="subtitle">
<span>${this.headerSubtitle}</span>
</slot>
<slot name="actionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>
<div class="body ha-scrollbar">
<slot></slot>
</div>
<slot name="footer" slot="footer"></slot>
</wa-dialog>
`;
}
private _handleShow = () => {
this._open = true;
this.dispatchEvent(new CustomEvent("opened"));
this.updateComplete.then(() => {
const focusElement = this.querySelector(
"[dialogInitialFocus]"
) as HTMLElement;
focusElement?.focus();
});
window.addEventListener("keydown", this._onKeyDown, true);
};
private _handleAfterHide = () => {
this._open = false;
this.dispatchEvent(new CustomEvent("closed"));
window.removeEventListener("keydown", this._onKeyDown, true);
};
public toggleSize = () => {
if (this.dialogSizeOnTitleClick === "none") {
return;
}
this.currentDialogSize =
this.currentDialogSize === this.dialogSizeOnTitleClick
? this.dialogSize
: this.dialogSizeOnTitleClick;
};
private _onKeyDown = (ev: KeyboardEvent) => {
if (!this._open || ev.defaultPrevented || ev.key !== "Enter") {
return;
}
const footer = this.querySelector("ha-dialog-footer") as HTMLElement | null;
if (!footer) return;
const primaryAction = footer.querySelector(
'[slot="primaryAction"]'
) as HTMLElement | null;
if (!primaryAction) return;
const isDisabled =
(primaryAction as any).disabled ?? primaryAction.hasAttribute("disabled");
if (isDisabled) return;
primaryAction.click();
ev.preventDefault();
ev.stopPropagation();
};
static styles = css`
:host([scrolled]) wa-dialog::part(header) {
max-width: 100%;
border-bottom: 1px solid
var(--dialog-scroll-divider-color, var(--divider-color));
}
wa-dialog {
--full-width: min(
calc(
100vw - var(--safe-area-inset-left, 0px) - var(
--safe-area-inset-right,
0px
)
),
95vw
);
--width: min(580px, 95vw);
--spacing: var(--dialog-content-padding, 24px);
--show-duration: var(--ha-dialog-show-duration, 200ms);
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
--wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
--wa-panel-border-radius: var(--ha-dialog-border-radius, 24px);
z-index: var(--dialog-z-index, 8);
max-width: 100%;
}
:host([current-dialog-size="small"]) wa-dialog {
--width: min(320px, var(--full-width));
}
:host([current-dialog-size="large"]) wa-dialog {
--width: min(720px, var(--full-width));
}
:host([current-dialog-size="full"]) wa-dialog {
--width: var(--full-width);
}
wa-dialog::part(dialog) {
min-width: var(--width, var(--full-width));
max-width: var(--width, var(--full-width));
max-height: 100vh;
position: var(--dialog-surface-position, relative);
margin-top: var(--dialog-surface-margin-top, auto);
transition:
min-width var(--ha-dialog-expand-duration, 200ms) ease-in-out,
max-width var(--ha-dialog-expand-duration, 200ms) ease-in-out;
display: flex;
flex-direction: column;
overflow: hidden;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
:host {
--ha-dialog-border-radius: 0px;
}
wa-dialog::part(dialog) {
min-height: 100vh;
}
}
wa-dialog::part(header) {
max-width: 100%;
overflow: hidden;
display: flex;
align-items: center;
padding: 24px 24px 16px 24px;
gap: 4px;
}
:host([has-custom-heading]) wa-dialog::part(header) {
max-width: 100%;
padding: 0;
}
wa-dialog::part(close-button),
wa-dialog::part(close-button__base) {
display: none;
}
.header-title-container {
display: flex;
align-items: center;
}
.header-title {
margin: 0;
margin-bottom: 0;
color: var(--mdc-dialog-heading-ink-color, rgba(0, 0, 0, 0.87));
font-size: var(--mdc-typography-headline6-font-size, 1.574rem);
line-height: var(--mdc-typography-headline6-line-height, 2rem);
font-weight: var(
--mdc-typography-headline6-font-weight,
var(--ha-font-weight-normal)
);
letter-spacing: var(--mdc-typography-headline6-letter-spacing, 0.0125em);
text-decoration: var(--mdc-typography-headline6-text-decoration, inherit);
text-transform: var(--mdc-typography-headline6-text-transform, inherit);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 12px;
}
wa-dialog::part(body) {
padding: 0;
display: flex;
flex-direction: column;
max-width: 100%;
overflow: hidden;
}
.body {
position: var(--dialog-content-position, relative);
padding: 0 var(--dialog-content-padding, 24px)
var(--dialog-content-padding, 24px) var(--dialog-content-padding, 24px);
overflow: auto;
flex-grow: 1;
}
:host([flexcontent]) .body {
max-width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
:host([hideactions]) wa-dialog::part(body) {
padding-bottom: var(--dialog-content-padding, 24px);
}
wa-dialog::part(footer) {
padding: 0;
}
::slotted([slot="footer"]) {
display: flex;
padding: 12px 16px 16px 16px;
gap: 12px;
justify-content: flex-end;
align-items: center;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-wa-dialog": HaWaDialog;
}
}

View File

@@ -0,0 +1,125 @@
import { ContextProvider } from "@lit/context";
import { mdiClose } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { labelsContext } from "../../../data/context";
import { subscribeLabelRegistry } from "../../../data/label_registry";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-md-dialog";
import type { HaMdDialog } from "../../ha-md-dialog";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
class DialogTargetDetails
extends SubscribeMixin(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: TargetDetailsDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
private _labelsContext = new ContextProvider(this, {
context: labelsContext,
initialValue: [],
});
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
}
public closeDialog() {
this._dialog?.close();
return true;
}
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeLabelRegistry(this.hass.connection!, (labels) => {
this._labelsContext.setValue(labels);
}),
];
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title"
>${this.hass.localize(
"ui.components.target-picker.target_details"
)}</span
>
<span slot="subtitle"
>${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}</span
>
</ha-dialog-header>
<div slot="content">
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
expand
></ha-target-picker-item-row>
</div>
</ha-md-dialog>
`;
}
static styles = css`
ha-md-dialog {
min-width: 400px;
max-height: 90%;
--dialog-content-padding: 8px 24px
max(var(--safe-area-inset-bottom, 0px), 32px);
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: 0;
min-width: 100%;
min-height: 100%;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-target-details": DialogTargetDetails;
}
}

View File

@@ -0,0 +1,100 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-md-dialog";
import type { HaMdDialog } from "../../ha-md-dialog";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../ha-target-picker-selector";
import type { TargetTypeFloorless } from "../ha-target-picker-selector";
import type { TargetPickerDialogParams } from "./show-dialog-target-picker";
@customElement("ha-dialog-target-picker")
class DialogTargetPicker extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: TargetPickerDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(params: TargetPickerDialogParams): void {
this._params = params;
}
public closeDialog() {
this._dialog?.close();
return true;
}
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog flexcontent open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">Pick target</span>
</ha-dialog-header>
<div class="content" slot="content">
<ha-target-picker-selector
mode="dialog"
autofocus
.hass=${this.hass}
@filter-types-changed=${this._handleUpdatePickerFilters}
.filterTypes=${this._params.typeFilter || []}
></ha-target-picker-selector>
</div>
</ha-md-dialog>
`;
}
private _handleUpdatePickerFilters(ev: CustomEvent<TargetTypeFloorless[]>) {
if (this._params?.updateTypeFilter) {
this._params.updateTypeFilter(ev.detail);
}
}
static styles = css`
ha-md-dialog {
--md-dialog-container-shape: 0;
min-width: 100%;
min-height: 100%;
--dialog-content-padding: 8px 0 0 0;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
}
ha-target-picker-selector {
flex: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-target-picker": DialogTargetPicker;
}
}

View File

@@ -0,0 +1,28 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import type { TargetType } from "../ha-target-picker-item-row";
export type NewBackupType = "automatic" | "manual";
export interface TargetDetailsDialogParams {
title: string;
type: TargetType;
itemId: string;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
includeDeviceClasses?: string[];
}
export const loadTargetDetailsDialog = () => import("./dialog-target-details");
export const showTargetDetailsDialog = (
element: HTMLElement,
params: TargetDetailsDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-target-details",
dialogImport: loadTargetDetailsDialog,
dialogParams: params,
});

View File

@@ -0,0 +1,28 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import type { TargetTypeFloorless } from "../ha-target-picker-selector";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
export interface TargetPickerDialogParams {
target: HassServiceTarget;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
includeDeviceClasses?: string[];
typeFilter?: TargetTypeFloorless[];
updateTypeFilter?: (types: TargetTypeFloorless[]) => void;
selectTarget: (target: HassServiceTarget) => void;
}
export const loadTargetPickerDialog = () => import("./dialog-target-picker");
export const showTargetPickerDialog = (
element: HTMLElement,
params: TargetPickerDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-target-picker",
dialogImport: loadTargetPickerDialog,
dialogParams: params,
});

View File

@@ -0,0 +1,354 @@
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-md-list";
import "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-tooltip";
import type { TargetType } from "./ha-target-picker-item-row";
@customElement("ha-target-picker-chips-selection")
export class HaTargetPickerChipsSelection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ reflect: true }) public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
protected render() {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
return html`
<div
class="mdc-chip ${classMap({
[this.type]: true,
})}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
: ""}
>
${iconPath
? html`<ha-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.icon=${iconPath}
></ha-icon>`
: this._iconImg
? html`<img
class="mdc-chip__icon mdc-chip__icon--leading"
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span id="title-${this.itemId}" class="mdc-chip__text"
>${name}</span
>
</span>
</span>
${this.type === "entity"
? nothing
: html`<span role="gridcell">
<ha-tooltip .for="expand-${this.itemId}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize(
"ui.components.target-picker.expand"
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${this.itemId}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>
</span>`}
<span role="gridcell">
<ha-tooltip .for="remove-${this.itemId}">
${this.hass.localize(
`ui.components.target-picker.remove_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize("ui.components.target-picker.remove")}
.path=${mdiClose}
hide-title
.id="remove-${this.itemId}"
.type=${this.type}
@click=${this._removeItem}
></ha-icon-button>
</span>
</div>
`;
}
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[itemId];
return {
name: floor?.name || itemId,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[itemId];
return {
name: area?.name || itemId,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device ? computeDeviceNameDisplay(device, this.hass) : itemId,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObject = this.hass.states[itemId];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const deviceName = device ? computeDeviceName(device) : undefined;
return {
name: entityName || deviceName || itemId,
stateObject,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
let color = label?.color ? computeCssColor(label.color) : undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return {
name: label?.name || itemId,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
color,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl({
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _removeItem(ev) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private _handleExpand(ev) {
ev.stopPropagation();
fireEvent(this, "expand-target-item", {
type: this.type,
id: this.itemId,
});
}
static styles = css`
${unsafeCSS(chipStyles)}
.mdc-chip {
color: var(--primary-text-color);
}
.mdc-chip.add {
color: rgba(0, 0, 0, 0.87);
}
.add-container {
position: relative;
display: inline-flex;
}
.mdc-chip:not(.add) {
cursor: default;
}
.mdc-chip ha-icon-button {
--mdc-icon-button-size: 24px;
display: flex;
align-items: center;
outline: none;
}
.mdc-chip ha-icon-button ha-svg-icon {
border-radius: 50%;
background: var(--secondary-text-color);
}
.mdc-chip__icon.mdc-chip__icon--trailing {
width: 16px;
height: 16px;
--mdc-icon-size: 14px;
color: var(--secondary-text-color);
margin-inline-start: 4px !important;
margin-inline-end: -4px !important;
direction: var(--direction);
}
.mdc-chip__icon--leading {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 20px;
border-radius: 50%;
padding: 6px;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: 4px !important;
direction: var(--direction);
}
.expand-btn {
margin-right: 0;
margin-inline-end: 0;
margin-inline-start: initial;
}
.mdc-chip.area:not(.add),
.mdc-chip.floor:not(.add) {
border: 1px solid #fed6a4;
background: var(--card-background-color);
}
.mdc-chip.area:not(.add) .mdc-chip__icon--leading,
.mdc-chip.area.add,
.mdc-chip.floor:not(.add) .mdc-chip__icon--leading,
.mdc-chip.floor.add {
background: #fed6a4;
}
.mdc-chip.device:not(.add) {
border: 1px solid #a8e1fb;
background: var(--card-background-color);
}
.mdc-chip.device:not(.add) .mdc-chip__icon--leading,
.mdc-chip.device.add {
background: #a8e1fb;
}
.mdc-chip.entity:not(.add) {
border: 1px solid #d2e7b9;
background: var(--card-background-color);
}
.mdc-chip.entity:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity.add {
background: #d2e7b9;
}
.mdc-chip.label:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover {
z-index: 5;
}
:host([disabled]) .mdc-chip {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
.tooltip-icon-img {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-chips-selection": HaTargetPickerChipsSelection;
}
}

View File

@@ -0,0 +1,107 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../ha-expansion-panel";
import "../ha-md-list";
import "./ha-target-picker-item-row";
import type { TargetType } from "./ha-target-picker-item-row";
@customElement("ha-target-picker-item-group")
export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: "entity" | "device" | "area" | "label";
@property({ attribute: false }) public items!: Partial<
Record<TargetType, string[]>
>;
@property({ type: Boolean }) public collapsed = false;
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Show only targets with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show only targets with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
if (items) {
count += items.length;
}
});
return html`<ha-expansion-panel .expanded=${!this.collapsed} left-chevron>
<div slot="header" class="heading">
${this.hass.localize(
`ui.components.target-picker.selected.${this.type}`,
{
count,
}
)}
</div>
<ha-md-list>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as "entity" | "device" | "area" | "label"}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-md-list>
</ha-expansion-panel>`;
}
static styles = css`
:host {
display: block;
--expansion-panel-content-padding: 0;
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: 4px 8px;
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
display: flex;
justify-content: space-between;
min-height: unset;
}
ha-md-list {
padding: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-item-group": HaTargetPickerItemGroup;
}
}

View File

@@ -0,0 +1,649 @@
import { consume } from "@lit/context";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
} from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } 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 {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
extractFromTarget,
type ExtractFromTargetResult,
} from "../../data/target";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon-button";
import "../ha-md-list";
import type { HaMdList } from "../ha-md-list";
import "../ha-md-list-item";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-svg-icon";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
export type TargetType = "entity" | "device" | "area" | "label" | "floor";
@customElement("ha-target-picker-item-row")
export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ reflect: true }) public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@property({ type: Boolean }) public expand = false;
@property({ type: Boolean, attribute: "last" }) public lastItem = false;
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
public subEntry = false;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@property({ attribute: false })
public parentEntries?: ExtractFromTargetResult;
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Show only targets with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show only targets with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
@state() private _iconImg?: string;
@state() private _domainName?: string;
@state() private _entries?: ExtractFromTargetResult;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
@query("ha-md-list-item") public item?: HaMdListItem;
@query("ha-md-list") public list?: HaMdList;
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
protected willUpdate(changedProps: PropertyValues) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
}
}
protected render() {
const { name, context, iconPath, fallbackIconPath, stateObject } =
this._itemData(this.type, this.itemId);
const showDevices = ["floor", "area", "label"].includes(this.type);
const showEntities = this.type !== "entity";
const entries = this.parentEntries || this._entries;
// Don't show sub entries that have no entities
if (
this.subEntry &&
this.type !== "entity" &&
(!entries || entries.referenced_entities.length === 0)
) {
return nothing;
}
return html`
<ha-md-list-item type="text">
<div slot="start">
${this.subEntry
? html`
<div class="horizontal-line-wrapper">
<div class="horizontal-line"></div>
</div>
`
: nothing}
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
>
</ha-state-icon>
`
: nothing}
</div>
<div slot="headline">${name}</div>
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
: this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry &&
((entries && (showEntities || showDevices)) || this._domainName)
? html`
<div slot="end" class="summary">
${showEntities && !this.expand
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
${showDevices
? html`<span class="secondary"
>${this.hass.localize(
"ui.components.target-picker.devices_count",
{
count: entries?.referenced_devices.length,
}
)}</span
>`
: nothing}
${this._domainName && !showDevices
? html`<span class="secondary domain"
>${this._domainName}</span
>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: nothing}
</ha-md-list-item>
${this.expand && entries && entries.referenced_entities
? this._renderEntries()
: nothing}
`;
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
let nextType: TargetType =
this.type === "floor"
? "area"
: this.type === "area"
? "device"
: "entity";
if (this.type === "label") {
if (entries?.referenced_areas.length) {
nextType = "area";
} else if (entries?.referenced_devices.length) {
nextType = "device";
}
}
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device"
? entries?.referenced_devices
: entries?.referenced_entities) || [];
const rows1Entries =
nextType === "entity"
? undefined
: rows1.map((rowItem) => {
const nextEntries = {
missing_areas: [] as string[],
missing_devices: [] as string[],
missing_floors: [] as string[],
missing_labels: [] as string[],
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities: [] as string[],
};
if (nextType === "area") {
nextEntries.referenced_devices =
entries?.referenced_devices.filter(
(device_id) =>
this.hass.devices?.[device_id]?.area_id === rowItem &&
entries?.referenced_entities.some(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
)
) || ([] as string[]);
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
return (
entity.area_id === rowItem ||
!entity.device_id ||
nextEntries.referenced_devices.includes(entity.device_id)
);
}) || ([] as string[]);
return nextEntries;
}
nextEntries.referenced_entities =
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === rowItem
) || ([] as string[]);
return nextEntries;
});
const rows2 =
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) =>
this.hass.entities[entity_id].labels.includes(this.itemId)
)
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id].area_id === this.itemId
)
: [];
return html`
<div class="entries-tree">
<div class="line-wrapper">
<div class="line"></div>
</div>
<ha-md-list class="entries">
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
.lastItem=${rows2.length === 0 && index === rows1.length - 1}
></ha-target-picker-item-row>
`
)}
${rows2.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
.lastItem=${index === rows2.length - 1}
></ha-target-picker-item-row>
`
)}
</ha-md-list>
</div>
`;
}
private async _updateItemData() {
if (this.type === "entity") {
this._entries = undefined;
return;
}
try {
const entries = await extractFromTarget(this.hass, {
[`${this.type}_id`]: [this.itemId],
});
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
}
);
}
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (
!hiddenAreaIds.includes(device.area_id || "") &&
(this.type !== "label" || device.labels.includes(this.itemId)) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
}
);
}
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
if (
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
entries.referenced_devices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
this.type === "label",
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
);
}
return false;
}
);
this._entries = entries;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
}
}
private _itemData = memoizeOne((type: TargetType, item: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[item];
return {
name: floor?.name || item,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[item];
return {
name: area?.name || item,
context: area.floor_id && this.hass.floors?.[area.floor_id]?.name,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[item];
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device ? computeDeviceNameDisplay(device, this.hass) : item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject = this.hass.states[item];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { area, device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
return {
name: entityName || deviceName || item,
context,
stateObject,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === item);
return {
name: label?.name || item,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private _removeItem(ev) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl({
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _openDetails() {
showTargetDetailsDialog(this, {
title: this._itemData(this.type, this.itemId).name,
type: this.type,
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
});
}
static styles = [
buttonLinkStyle,
css`
:host {
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--md-list-item-two-line-container-height: 56px;
}
:host([expand]:not([sub-entry])) ha-md-list-item {
border: 2px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-fill-neutral-quiet-resting);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
state-badge {
color: var(--ha-color-on-neutral-quiet);
}
img {
width: 24px;
height: 24px;
}
ha-icon-button {
--mdc-icon-button-size: 32px;
}
.summary {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: var(--ha-line-height-condensed);
}
:host([sub-entry]) .summary {
margin-right: 48px;
}
.summary .main {
font-weight: var(--ha-font-weight-medium);
}
.summary .secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.domain {
font-family: var(--ha-font-family-code);
}
.entries-tree {
display: flex;
position: relative;
}
.entries-tree .line-wrapper {
padding: 20px;
}
.entries-tree .line-wrapper .line {
border-left: 2px dashed var(--divider-color);
height: calc(100% - 28px);
position: absolute;
top: 0;
}
:host([sub-entry]) .entries-tree .line-wrapper .line {
height: calc(100% - 12px);
top: -18px;
}
.entries {
padding: 0;
--md-item-overflow: visible;
}
.horizontal-line-wrapper {
position: relative;
}
.horizontal-line-wrapper .horizontal-line {
position: absolute;
top: 11px;
margin-inline-start: -28px;
width: 29px;
border-top: 2px dashed var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-item-row": HaTargetPickerItemRow;
}
}

View File

@@ -0,0 +1,501 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { mdiCheck, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import {
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../../data/area_floor";
import {
getEntities,
type EntityComboBoxItem,
} from "../../data/entity_registry";
import { HaFuse } from "../../resources/fuse";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "../entity/state-badge";
import "../ha-button";
import "../ha-combo-box-item";
import "../ha-floor-icon";
import "../ha-md-list";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-textfield";
import type { HaTextField } from "../ha-textfield";
import "../ha-tree-indicator";
import type { TargetType } from "./ha-target-picker-item-row";
const SEPARATOR = "________";
export type TargetTypeFloorless = Exclude<TargetType, "floor">;
@customElement("ha-target-picker-selector")
export class HaTargetPickerSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public filterTypes: TargetTypeFloorless[] =
[];
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@state() private _searchTerm = "";
@state() private _listScrolled = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-textfield
.label=${this.hass.localize("ui.common.search")}
@input=${this._searchChanged}
.value=${this._searchTerm}
></ha-textfield>
<div class="filter">${this._renderFilterButtons()}</div>
<lit-virtualizer
scroller
.items=${this._getItems()}
.renderItem=${this._renderRow}
@scroll=${this._onScrollList}
class=${this._listScrolled ? "scrolled" : ""}
>
</lit-virtualizer>
`;
}
private _renderFilterButtons() {
const filter: (TargetTypeFloorless | "separator")[] = [
"entity",
"device",
"area",
"separator",
"label",
];
return filter.map((filterType) => {
if (filterType === "separator") {
return html`<div class="separator"></div>`;
}
const selected = this.filterTypes.includes(filterType);
return html`
<ha-button
@click=${this._toggleFilter}
.type=${filterType}
size="small"
.variant=${selected ? "brand" : "neutral"}
appearance="filled"
>
${selected
? html`<ha-svg-icon slot="start" .path=${mdiCheck}></ha-svg-icon>`
: nothing}
${filterType.charAt(0).toUpperCase() +
filterType.slice(1)}s</ha-button
>
`;
});
}
private _renderRow = (item) => {
if (!item) {
return nothing;
}
if (typeof item === "string") {
if (item === "padding") {
return html`<div class="bottom-padding"></div>`;
}
return html`<div class="title">${item}</div>`;
}
if (item.type === "area" || item.type === "floor") {
return this._areaRowRenderer(item);
}
if ("domain" in item) {
// TODO device row
}
if ("stateObj" in item) {
return this._entityRowRenderer(item);
}
// label or empty
return html`
<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
};
private _filterAreasAndFloors(items: FloorComboBoxItem[]) {
const index = this._areaFuseIndex(items);
const fuse = new HaFuse(items, { shouldSort: false }, index);
const results = fuse.multiTermsSearch(this._searchTerm);
let filteredItems = items as FloorComboBoxItem[];
if (results) {
filteredItems = results.map((result) => result.item);
}
return filteredItems;
}
private _filterEntities(items: EntityComboBoxItem[]) {
const fuseIndex = this._entityFuseIndex(items);
const fuse = new HaFuse(items, { shouldSort: false }, fuseIndex);
const results = fuse.multiTermsSearch(this._searchTerm);
let filteredItems = items as EntityComboBoxItem[];
if (results) {
filteredItems = results.map((result) => result.item);
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex(
(item) => item.stateObj?.entity_id === this._searchTerm
);
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
}
private _getItems = () => {
const items: (
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
)[] = [];
if (this.filterTypes.length === 0 || this.filterTypes.includes("entity")) {
let entities = getEntities(this.hass);
if (this._searchTerm) {
entities = this._filterEntities(entities);
}
if (entities.length > 0 && this.filterTypes.length !== 1) {
items.push(
this.hass.localize("ui.components.target-picker.type.entities")
); // title
}
items.push(...entities);
}
if (this.filterTypes.length === 0 || this.filterTypes.includes("area")) {
let areasAndFloors = getAreasAndFloors(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
)
);
if (this._searchTerm) {
areasAndFloors = this._filterAreasAndFloors(areasAndFloors);
}
if (areasAndFloors.length > 0 && this.filterTypes.length !== 1) {
items.push(
this.hass.localize("ui.components.target-picker.type.areas")
); // title
}
items.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (this._searchTerm && items.length === 0) {
items.push({
id: "empty",
primary: this.hass.localize(
"ui.components.target-picker.no_target_found",
{ term: html`<span class="search-term">${this._searchTerm}</span>` }
),
});
} else if (items.length === 0) {
items.push({
id: "empty",
primary: this.hass.localize("ui.components.target-picker.no_targets"),
});
}
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
}
return items;
};
private _areaFuseIndex = memoizeOne((states: FloorComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _entityFuseIndex = memoizeOne((states: EntityComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _searchChanged(ev: Event) {
const textfield = ev.target as HaTextField;
const value = textfield.value.trim();
this._searchTerm = value;
}
private _areaRowRenderer = (item) => {
const rtl = computeRTL(this.hass);
const hasFloor = item.type === "area" && item.area?.floor_id;
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${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=${item.last}
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 get _showEntityId() {
return this.hass.userData?.showEntityIdPicker;
}
private _entityRowRenderer = (item) => {
const showEntityId = this._showEntityId;
return html`
<ha-combo-box-item type="button" compact>
${item.icon_path
? html`
<ha-svg-icon
slot="start"
style="margin: 0 4px"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${item.stateObj.entity_id}
</span>
`
: nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _toggleFilter(ev: any) {
const type = ev.target.type as TargetTypeFloorless;
if (!type) {
return;
}
const index = this.filterTypes.indexOf(type);
if (index === -1) {
this.filterTypes = [...this.filterTypes, type];
} else {
this.filterTypes = this.filterTypes.filter((t) => t !== type);
}
// Reset scroll position when filter changes
if (this._virtualizerElement) {
this._virtualizerElement.scrollTop = 0;
}
fireEvent(this, "filter-types-changed", this.filterTypes);
}
@eventOptions({ passive: true })
private _onScrollList(ev) {
const top = ev.target.scrollTop ?? 0;
this._listScrolled = top > 0;
}
static styles = [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 12px;
flex: 1;
}
ha-textfield {
padding: 0 12px;
}
.filter {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 0 12px;
--ha-button-border-radius: var(--ha-border-radius-md);
}
.filter .separator {
height: 32px;
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.title {
width: 100%;
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: 4px 8px;
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
}
:host([mode="dialog"]) .title {
padding: 4px 16px;
}
:host([mode="dialog"]) .filter,
:host([mode="dialog"]) ha-textfield {
padding: 0 16px;
}
ha-combo-box-item {
width: 100%;
}
lit-virtualizer {
flex: 1;
box-shadow: none;
transition: box-shadow 180ms ease-in-out;
}
lit-virtualizer.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
.bottom-padding {
height: max(var(--safe-area-inset-bottom, 0px), 32px);
width: 100%;
}
.search-term {
color: var(--primary-color);
font-weight: var(--ha-font-weight-medium);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-selector": HaTargetPickerSelector;
}
interface HASSDomEvents {
"filter-types-changed": TargetTypeFloorless[];
}
}

297
src/data/area_floor.ts Normal file
View File

@@ -0,0 +1,297 @@
import memoizeOne from "memoize-one";
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 { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import { getFloorAreaLookup, type FloorRegistryEntry } from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
floor?: FloorRegistryEntry;
area?: AreaRegistryEntry;
}
export interface AreaFloorValue {
id: string;
type: "floor" | "area";
}
export const getAreasAndFloors = (
states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
formatId: (value: AreaFloorValue) => string,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[],
excludeFloors?: string[]
) =>
memoizeOne(
(
haFloorsMemo: HomeAssistant["floors"],
haAreasMemo: HomeAssistant["areas"],
haDevicesMemo: HomeAssistant["devices"],
haEntitiesMemo: HomeAssistant["entities"],
includeDomainsMemo?: string[],
excludeDomainsMemo?: string[],
includeDeviceClassesMemo?: string[],
deviceFilterMemo?: HaDevicePickerDeviceFilterFunc,
entityFilterMemo?: HaEntityPickerEntityFilterFunc,
excludeAreasMemo?: string[],
excludeFloorsMemo?: string[]
): FloorComboBoxItem[] => {
const floors = Object.values(haFloorsMemo);
const areas = Object.values(haAreasMemo);
const devices = Object.values(haDevicesMemo);
const entities = Object.values(haEntitiesMemo);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomainsMemo ||
excludeDomainsMemo ||
includeDeviceClassesMemo ||
deviceFilterMemo ||
entityFilterMemo
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomainsMemo) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomainsMemo.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomainsMemo.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomainsMemo) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomainsMemo.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomainsMemo.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClassesMemo) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClassesMemo.includes(
stateObj.attributes.device_class
)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClassesMemo.includes(
stateObj.attributes.device_class
)
);
});
}
if (deviceFilterMemo) {
inputDevices = inputDevices!.filter((device) =>
deviceFilterMemo!(device)
);
}
if (entityFilterMemo) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilterMemo(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilterMemo!(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 (excludeAreasMemo) {
outputAreas = outputAreas.filter(
(area) => !excludeAreasMemo!.includes(area.area_id)
);
}
if (excludeFloorsMemo) {
outputAreas = outputAreas.filter(
(area) =>
!area.floor_id || !excludeFloorsMemo!.includes(area.floor_id)
);
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
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: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ 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],
};
})
);
});
items.push(
...unassisgnedAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ 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 items;
}
)(
haFloors,
haAreas,
haDevices,
haEntities,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeAreas,
excludeFloors
);

View File

@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { arrayLiteralIncludes } from "../common/array/literal-includes";
export const UNAVAILABLE = "unavailable";
@@ -10,3 +11,5 @@ export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const;
export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES);
export const isOffState = arrayLiteralIncludes(OFF_STATES);
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;

View File

@@ -1,12 +1,16 @@
import type { Connection } from "home-assistant-js-websocket";
import type { Connection, HassEntity } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import memoizeOne from "memoize-one";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { debounce } from "../common/util/debounce";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import { domainToName } from "./integration";
import type { LightColor } from "./light";
import type { RegistryEntry } from "./registry";
@@ -324,3 +328,141 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
});
export interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export const getEntities = (
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
): EntityComboBoxItem[] =>
memoizeOne(
(
states: HomeAssistant["states"],
isRTLMemo: boolean,
includeDomainsMemo?: string[],
excludeDomainsMemo?: string[],
entityFilterMemo?: HaEntityPickerEntityFilterFunc,
includeDeviceClassesMemo?: string[],
includeUnitOfMeasurementMemo?: string[],
includeEntitiesMemo?: string[],
excludeEntitiesMemo?: string[]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(states);
if (includeEntitiesMemo) {
entityIds = entityIds.filter((entityId) =>
includeEntitiesMemo.includes(entityId)
);
}
if (excludeEntitiesMemo) {
entityIds = entityIds.filter(
(entityId) => !excludeEntitiesMemo.includes(entityId)
);
}
if (includeDomainsMemo) {
entityIds = entityIds.filter((eid) =>
includeDomainsMemo.includes(computeDomain(eid))
);
}
if (excludeDomainsMemo) {
entityIds = entityIds.filter(
(eid) => !excludeDomainsMemo.includes(computeDomain(eid))
);
}
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = hass.formatEntityName(stateObj, "entity");
const deviceName = hass.formatEntityName(stateObj, "device");
const areaName = hass.formatEntityName(stateObj, "area");
const domainName = domainToName(hass.localize, computeDomain(entityId));
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTLMemo ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
id: entityId,
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj,
};
});
if (includeDeviceClassesMemo) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClassesMemo.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurementMemo) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurementMemo.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilterMemo) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === value ||
(item.stateObj && entityFilterMemo!(item.stateObj))
);
}
return items;
}
)(
hass.states,
computeRTL(hass),
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities
);

155
src/data/target.ts Normal file
View File

@@ -0,0 +1,155 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import type { DeviceRegistryEntry } from "./device_registry";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
export interface ExtractFromTargetResult {
missing_areas: string[];
missing_devices: string[];
missing_floors: string[];
missing_labels: string[];
referenced_areas: string[];
referenced_devices: string[];
referenced_entities: string[];
}
export const extractFromTarget = async (
hass: HomeAssistant,
target: HassServiceTarget
) =>
hass.callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
});
export const areaMeetsFilter = (
area: AreaRegistryEntry,
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
if (
areaDevices.some((device) =>
deviceMeetsFilter(
device,
entities,
deviceFilter,
includeDomains,
includeDeviceClasses,
states,
entityFilter
)
)
) {
return true;
}
const areaEntities = Object.values(entities).filter(
(entity) => entity.area_id === area.area_id
);
if (
areaEntities.some((entity) =>
entityRegMeetsFilter(
entity,
false,
includeDomains,
includeDeviceClasses,
states,
entityFilter
)
)
) {
return true;
}
return false;
};
export const deviceMeetsFilter = (
device: DeviceRegistryEntry,
entities: HomeAssistant["entities"],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
): boolean => {
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
if (
!devEntities.some((entity) =>
entityRegMeetsFilter(
entity,
false,
includeDomains,
includeDeviceClasses,
states,
entityFilter
)
)
) {
return false;
}
if (deviceFilter) {
return deviceFilter(device);
}
return true;
};
export const entityRegMeetsFilter = (
entity: EntityRegistryDisplayEntry,
includeSecondary = false,
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
): boolean => {
if (entity.hidden || (entity.entity_category && !includeSecondary)) {
return false;
}
if (
includeDomains &&
!includeDomains.includes(computeDomain(entity.entity_id))
) {
return false;
}
if (includeDeviceClasses) {
const stateObj = states?.[entity.entity_id];
if (!stateObj) {
return false;
}
if (
!stateObj.attributes.device_class ||
!includeDeviceClasses!.includes(stateObj.attributes.device_class)
) {
return false;
}
}
if (entityFilter) {
const stateObj = states?.[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
}
return true;
};

View File

@@ -28,7 +28,8 @@ import { getEntityEntryContext } from "../../common/entity/context/get_entity_co
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate";
import "../../components/ha-button-menu";
import "../../components/ha-wa-dialog";
import "../../components/ha-dialog";
import "../../components/ha-dialog-header";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-list-item";
@@ -90,6 +91,8 @@ const DEFAULT_VIEW: View = "info";
export class MoreInfoDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _parentEntityIds: string[] = [];
@state() private _entityId?: string | null;
@@ -121,6 +124,7 @@ export class MoreInfoDialog extends LitElement {
this._currView = params.view || DEFAULT_VIEW;
this._initialView = params.view || DEFAULT_VIEW;
this._childView = undefined;
this.large = false;
this._loadEntityRegistryEntry();
}
@@ -346,202 +350,200 @@ export class MoreInfoDialog extends LitElement {
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
return html`
<ha-wa-dialog
<ha-dialog
open
@closed=${this.closeDialog}
@opened=${this._handleOpened}
.backLabel=${this.hass.localize(
"ui.dialogs.more_info_control.back_to_info"
)}
.backAction=${showCloseIcon ? undefined : this._goBack}
.headerTitle=${title}
.scrimDismissable=${this._isEscapeEnabled}
.dialogSizeOnTitleClick=${"full"}
.escapeKeyAction=${this._isEscapeEnabled ? undefined : ""}
.heading=${title}
hideActions
flexContent
>
${showCloseIcon
? html`
<ha-icon-button
slot="navigationIcon"
data-dialog="close"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
`
: html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._goBack}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.back_to_info"
)}
></ha-icon-button-prev>
`}
<span slot="title" @click=${this._toggleSize} class="title">
${breadcrumb.length > 0
? !__DEMO__ && isAdmin
? html`
<button
class="breadcrumb"
@click=${this._breadcrumbClick}
aria-label=${breadcrumb.join(" > ")}
>
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</button>
`
: html`
<p class="breadcrumb">
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</p>
`
: nothing}
<p class="main">${title}</p>
</span>
${isDefaultView
? html`
${this._shouldShowHistory(domain)
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.history"
)}
.path=${mdiChartBoxOutline}
@click=${this._goToHistory}
></ha-icon-button>
`
: nothing}
${!__DEMO__ && isAdmin
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.settings"
)}
.path=${mdiCogOutline}
@click=${this._goToSettings}
></ha-icon-button>
<ha-button-menu
corner="BOTTOM_END"
menu-corner="END"
slot="actionItems"
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${deviceId
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToDevice}
>
${this.hass.localize(
"ui.dialogs.more_info_control.device_or_service_info",
{
type: this.hass.localize(
`ui.dialogs.more_info_control.device_type.${deviceType}`
),
}
)}
<ha-svg-icon
slot="graphic"
.path=${deviceType === "service"
? mdiTransitConnectionVariant
: mdiDevices}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this._shouldShowEditIcon(domain, stateObj)
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToEdit}
>
${this.hass.localize(
"ui.dialogs.more_info_control.edit"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPencilOutline}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this._entry &&
stateObj &&
domain === "light" &&
lightSupportsFavoriteColors(stateObj)
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._toggleInfoEditMode}
>
${this._infoEditMode
? this.hass.localize(
`ui.dialogs.more_info_control.exit_edit_mode`
)
: this.hass.localize(
`ui.dialogs.more_info_control.${domain}.edit_mode`
)}
<ha-svg-icon
slot="graphic"
.path=${this._infoEditMode
? mdiPencilOff
: mdiPencil}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
<ha-list-item
graphic="icon"
@request-selected=${this._goToRelated}
>
${this.hass.localize(
"ui.dialogs.more_info_control.related"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
`
: nothing}
`
: isSpecificInitialView
<ha-dialog-header slot="heading">
${showCloseIcon
? html`
<ha-button-menu
corner="BOTTOM_END"
menu-corner="END"
slot="actionItems"
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
@request-selected=${this._resetInitialView}
>
${this.hass.localize("ui.dialogs.more_info_control.info")}
<ha-svg-icon
slot="graphic"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<ha-icon-button
slot="navigationIcon"
dialogAction="cancel"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
`
: nothing}
: html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._goBack}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.back_to_info"
)}
></ha-icon-button-prev>
`}
<span slot="title" @click=${this._enlarge} class="title">
${breadcrumb.length > 0
? !__DEMO__ && isAdmin
? html`
<button
class="breadcrumb"
@click=${this._breadcrumbClick}
aria-label=${breadcrumb.join(" > ")}
>
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</button>
`
: html`
<p class="breadcrumb">
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
</p>
`
: nothing}
<p class="main">${title}</p>
</span>
${isDefaultView
? html`
${this._shouldShowHistory(domain)
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.history"
)}
.path=${mdiChartBoxOutline}
@click=${this._goToHistory}
></ha-icon-button>
`
: nothing}
${!__DEMO__ && isAdmin
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.settings"
)}
.path=${mdiCogOutline}
@click=${this._goToSettings}
></ha-icon-button>
<ha-button-menu
corner="BOTTOM_END"
menu-corner="END"
slot="actionItems"
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${deviceId
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToDevice}
>
${this.hass.localize(
"ui.dialogs.more_info_control.device_or_service_info",
{
type: this.hass.localize(
`ui.dialogs.more_info_control.device_type.${deviceType}`
),
}
)}
<ha-svg-icon
slot="graphic"
.path=${deviceType === "service"
? mdiTransitConnectionVariant
: mdiDevices}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this._shouldShowEditIcon(domain, stateObj)
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToEdit}
>
${this.hass.localize(
"ui.dialogs.more_info_control.edit"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPencilOutline}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this._entry &&
stateObj &&
domain === "light" &&
lightSupportsFavoriteColors(stateObj)
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._toggleInfoEditMode}
>
${this._infoEditMode
? this.hass.localize(
`ui.dialogs.more_info_control.exit_edit_mode`
)
: this.hass.localize(
`ui.dialogs.more_info_control.${domain}.edit_mode`
)}
<ha-svg-icon
slot="graphic"
.path=${this._infoEditMode
? mdiPencilOff
: mdiPencil}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
<ha-list-item
graphic="icon"
@request-selected=${this._goToRelated}
>
${this.hass.localize(
"ui.dialogs.more_info_control.related"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
`
: nothing}
`
: isSpecificInitialView
? html`
<ha-button-menu
corner="BOTTOM_END"
menu-corner="END"
slot="actionItems"
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
@request-selected=${this._resetInitialView}
>
${this.hass.localize("ui.dialogs.more_info_control.info")}
<ha-svg-icon
slot="graphic"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
`
: nothing}
</ha-dialog-header>
${keyed(
this._entityId,
html`
@@ -606,7 +608,7 @@ export class MoreInfoDialog extends LitElement {
</div>
`
)}
</ha-wa-dialog>
</ha-dialog>
`;
}
@@ -628,8 +630,8 @@ export class MoreInfoDialog extends LitElement {
this._entry = ev.detail;
}
private _toggleSize() {
this.shadowRoot?.querySelector("ha-wa-dialog")?.toggleSize();
private _enlarge() {
this.large = !this.large;
}
private _handleOpened() {
@@ -662,7 +664,7 @@ export class MoreInfoDialog extends LitElement {
return [
haStyleDialog,
css`
ha-wa-dialog {
ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: max(
@@ -691,21 +693,34 @@ export class MoreInfoDialog extends LitElement {
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-wa-dialog {
--dialog-surface-margin-top: var(--safe-area-inset-top, 0px);
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: 0px;
}
}
@media all and (min-width: 600px) and (min-height: 501px) {
ha-dialog {
--mdc-dialog-min-width: 580px;
--mdc-dialog-max-width: 580px;
--mdc-dialog-max-height: calc(100% - 72px);
}
.main-title {
cursor: default;
}
:host([large]) ha-dialog {
--mdc-dialog-min-width: 90vw;
--mdc-dialog-max-width: 90vw;
}
}
.title {
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 0 0 -10px 0;
}
.title p {

View File

@@ -5,9 +5,8 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import "../../../../components/ha-alert";
import "../../../../components/ha-area-picker";
import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-labels-picker";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
@@ -58,10 +57,10 @@ class DialogDeviceRegistryDetail extends LitElement {
}
const device = this._params.device;
return html`
<ha-wa-dialog
<ha-dialog
open
@closed=${this.closeDialog}
.headerTitle=${computeDeviceNameDisplay(device, this.hass)}
.heading=${computeDeviceNameDisplay(device, this.hass)}
>
<div>
${this._error
@@ -132,24 +131,22 @@ class DialogDeviceRegistryDetail extends LitElement {
</div>
</div>
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
data-dialog="close"
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${this._submitting}
@click=${this._updateEntry}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog>
`;
}

View File

@@ -1,10 +1,11 @@
import { ContextProvider } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import {
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiImagePlus,
} from "@mdi/js";
import type { ActionDetail } from "@material/mwc-list";
import { differenceInHours } from "date-fns";
import type {
HassServiceTarget,
@@ -27,32 +28,35 @@ import {
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
import "../../components/ha-spinner";
import "../../components/ha-button-menu";
import "../../components/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-button-menu";
import "../../components/ha-list-item";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import { labelsContext } from "../../data/context";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
subscribeHistory,
mergeHistoryResults,
convertStatisticsToHistory,
mergeHistoryResults,
subscribeHistory,
} from "../../data/history";
import { subscribeLabelRegistry } from "../../data/label_registry";
import { fetchStatistics } from "../../data/recorder";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { fileDownload } from "../../util/file_download";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
class HaPanelHistory extends LitElement {
class HaPanelHistory extends SubscribeMixin(LitElement) {
@property({ attribute: false }) hass!: HomeAssistant;
@property({ reflect: true, type: Boolean }) public narrow = false;
@@ -89,6 +93,11 @@ class HaPanelHistory extends LitElement {
private _interval?: number;
private _labelsContext = new ContextProvider(this, {
context: labelsContext,
initialValue: [],
});
public constructor() {
super();
@@ -108,6 +117,14 @@ class HaPanelHistory extends LitElement {
}
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeLabelRegistry(this.hass.connection!, (labels) => {
this._labelsContext.setValue(labels);
}),
];
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeHistory();
@@ -182,6 +199,7 @@ class HaPanelHistory extends LitElement {
.disabled=${this._isLoading}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
${this._isLoading

View File

@@ -1,9 +1,15 @@
import { ContextProvider } from "@lit/context";
import { mdiRefresh } from "@mdi/js";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -16,20 +22,21 @@ import "../../components/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import { labelsContext } from "../../data/context";
import { subscribeLabelRegistry } from "../../data/label_registry";
import { filterLogbookCompatibleEntities } from "../../data/logbook";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
import { storage } from "../../common/decorators/storage";
import { ensureArray } from "../../common/array/ensure-array";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
export class HaPanelLogbook extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -51,6 +58,11 @@ export class HaPanelLogbook extends LitElement {
@state() private _sensorNumericDeviceClasses?: string[] = [];
private _labelsContext = new ContextProvider(this, {
context: labelsContext,
initialValue: [],
});
public constructor() {
super();
@@ -63,6 +75,14 @@ export class HaPanelLogbook extends LitElement {
this._time = { range: [start, end] };
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeLabelRegistry(this.hass.connection!, (labels) => {
this._labelsContext.setValue(labels);
}),
];
}
private _goBack(): void {
goBack();
}
@@ -108,6 +128,7 @@ export class HaPanelLogbook extends LitElement {
.value=${this._targetPickerValue}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>

View File

@@ -4,14 +4,12 @@ import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/ha-entity-picker";
import type {
HaEntityPicker,
HaEntityPickerEntityFilterFunc,
} from "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon-button";
import "../../../components/ha-sortable";
import type { HomeAssistant } from "../../../types";
import type { EntityConfig } from "../entity-rows/types";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
@customElement("hui-entity-editor")
export class HuiEntityEditor extends LitElement {

View File

@@ -22,8 +22,8 @@ import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card";
import { targetStruct } from "../../../../data/script";
import type { HaEntityPickerEntityFilterFunc } from "../../../../components/entity/ha-entity-picker";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HaEntityPickerEntityFilterFunc } from "../../../../data/entity";
const cardConfigStruct = assign(
baseLovelaceCardConfig,

View File

@@ -52,6 +52,13 @@ export const waColorStyles = css`
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet);
--wa-color-surface-default: var(--white-color);
--wa-panel-border-radius: var(--ha-border-radius-3xl);
--wa-panel-border-style: solid;
--wa-panel-border-width: 1px;
--wa-color-surface-border: var(--ha-color-border-neutral-quiet);
--wa-focus-ring-color: var(--ha-color-neutral-60);
--wa-shadow-l: box-shadow: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
}
`;

View File

@@ -663,20 +663,50 @@
},
"target-picker": {
"expand": "Expand",
"collapse": "Collapse",
"expand_floor_id": "Split this floor into separate areas.",
"expand_area_id": "Split this area into separate devices and entities.",
"expand_device_id": "Split this device into separate entities.",
"expand_label_id": "Split this label into separate areas, devices and entities.",
"add_target": "Add target",
"remove": "Remove",
"remove_floor_id": "Remove floor",
"remove_floors": "Remove floors",
"remove_area_id": "Remove area",
"remove_areas": "Remove areas",
"remove_device_id": "Remove device",
"remove_devices": "Remove devices",
"remove_entity_id": "Remove entity",
"remove_entitys": "Remove entities",
"remove_label_id": "Remove label",
"remove_labels": "Remove labels",
"add_area_id": "Choose area",
"add_device_id": "Choose device",
"add_entity_id": "Choose entity",
"add_label_id": "Choose label"
"add_label_id": "Choose label",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for term {term}",
"selected": {
"entity": "Entities: {count}",
"device": "Devices: {count}",
"area": "Areas: {count}",
"label": "Labels: {count}",
"floor": "Floors: {count}"
},
"type": {
"area": "Area",
"areas": "Areas",
"device": "Device",
"devices": "Devices",
"entity": "Entity",
"entities": "Entities",
"label": "Label",
"labels": "Labels",
"floor": "Floor"
}
},
"subpage-data-table": {
"filters": "Filters",

View File

@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import { getAreaContext } from "../../../../src/common/entity/context/get_area_context";
import type { HomeAssistant } from "../../../../src/types";
import { mockArea, mockFloor } from "./context-mock";
describe("getAreaContext", () => {
@@ -9,14 +8,7 @@ describe("getAreaContext", () => {
area_id: "area_1",
});
const hass = {
areas: {
area_1: area,
},
floors: {},
} as unknown as HomeAssistant;
const result = getAreaContext(area, hass);
const result = getAreaContext(area, {});
expect(result).toEqual({
area,
@@ -34,16 +26,9 @@ describe("getAreaContext", () => {
floor_id: "floor_1",
});
const hass = {
areas: {
area_2: area,
},
floors: {
floor_1: floor,
},
} as unknown as HomeAssistant;
const result = getAreaContext(area, hass);
const result = getAreaContext(area, {
floor_1: floor,
});
expect(result).toEqual({
area,