Compare commits

..

15 Commits

Author SHA1 Message Date
Aidan Timson
aa55047656 Revert 2025-10-08 11:39:34 +01:00
Aidan Timson
c1f6336e74 Remove sidebar code 2025-10-08 11:20:50 +01:00
Aidan Timson
c777124f69 POC: view transitions 2025-10-07 16:40:27 +01:00
Aidan Timson
95e1057941 Respect reduced motion 2025-10-07 16:26:57 +01:00
Aidan Timson
d4e1362d1e Add to hui views 2025-10-07 16:22:13 +01:00
Aidan Timson
cd72e74d08 Add animations 2025-10-07 16:22:13 +01:00
Aidan Timson
2d92711003 Cleanup 2025-10-07 16:22:13 +01:00
Aidan Timson
34750443ef Use index based delay 2025-10-07 16:22:13 +01:00
Aidan Timson
db14c085d4 Faster 2025-10-07 16:22:13 +01:00
Aidan Timson
31f9a96337 Fade in menu button 2025-10-07 16:22:13 +01:00
Aidan Timson
479a251958 Move 2025-10-07 16:22:13 +01:00
Aidan Timson
9b95357038 Cap stagger at 8 items 2025-10-07 16:22:13 +01:00
Aidan Timson
c380deb07e Animate sidebar 2025-10-07 16:22:13 +01:00
Aidan Timson
f6aadd1c27 Set base themable animation durations 2025-10-07 16:22:13 +01:00
Aidan Timson
8fc3f01e62 Create fade in slide down shared animation 2025-10-07 16:22:13 +01:00
91 changed files with 2414 additions and 6677 deletions

View File

@@ -1,3 +0,0 @@
---
title: Dialog (ha-wa-dialog)
---

View File

@@ -1,523 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mdiCog, mdiHelp } from "@mdi/js";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dialog-footer";
import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-wa-dialog";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
{ type: "string", name: "Email", default: "" },
];
type DialogType =
| false
| "basic"
| "basic-subtitle-below"
| "basic-subtitle-above"
| "form"
| "actions";
@customElement("demo-components-ha-wa-dialog")
export class DemoHaWaDialog extends LitElement {
@state() private _openDialog: DialogType = false;
protected render() {
return html`
<div class="content">
<h1>Dialog <code>&lt;ha-wa-dialog&gt;</code></h1>
<p class="subtitle">Dialog component built with WebAwesome.</p>
<h2>Demos</h2>
<div class="buttons">
<ha-button @click=${this._handleOpenDialog("basic")}
>Basic dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("basic-subtitle-below")}
>Basic dialog with subtitle below</ha-button
>
<ha-button @click=${this._handleOpenDialog("basic-subtitle-above")}
>Basic dialog with subtitle above</ha-button
>
<ha-button @click=${this._handleOpenDialog("form")}
>Dialog with form</ha-button
>
<ha-button @click=${this._handleOpenDialog("actions")}
>Dialog with actions</ha-button
>
</div>
<ha-wa-dialog
.open=${this._openDialog === "basic"}
header-title="Basic dialog"
@closed=${this._handleClosed}
>
<div>Dialog content</div>
</ha-wa-dialog>
<ha-wa-dialog
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Basic dialog with subtitle"
header-subtitle="This is a basic dialog with a subtitle below"
@closed=${this._handleClosed}
>
<div>Dialog content</div>
</ha-wa-dialog>
<ha-wa-dialog
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Dialog with subtitle above"
header-subtitle="This is a basic dialog with a subtitle above"
header-subtitle-position="above"
@closed=${this._handleClosed}
>
<div>Dialog content</div>
</ha-wa-dialog>
<ha-wa-dialog
.open=${this._openDialog === "form"}
header-title="Dialog with form"
header-subtitle="This is a dialog with a form and a footer"
prevent-scrim-close
@closed=${this._handleClosed}
>
<ha-form autofocus .schema=${SCHEMA}></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
data-dialog="close"
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button data-dialog="close" slot="primaryAction" variant="accent"
>Submit</ha-button
>
</ha-dialog-footer>
</ha-wa-dialog>
<ha-wa-dialog
.open=${this._openDialog === "actions"}
header-title="Dialog with actions"
header-subtitle="This is a dialog with header actions"
@closed=${this._handleClosed}
>
<div slot="headerActionItems">
<ha-icon-button label="Settings" path=${mdiCog}></ha-icon-button>
<ha-icon-button label="Help" path=${mdiHelp}></ha-icon-button>
</div>
<div>Dialog content</div>
</ha-wa-dialog>
<h2>Design</h2>
<h3>Width</h3>
<p>There are multiple widths available for the dialog.</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>small</code></td>
<td><code>min(320px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>medium</code></td>
<td><code>min(580px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>large</code></td>
<td><code>min(720px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>full</code></td>
<td><code>var(--full-width)</code></td>
</tr>
</tbody>
</table>
<p>
<code>--full-width</code> is calculated based on the available width
of the screen. 95vw is the maximum width of the dialog on a large
screen, while on a small screen it is 100vw minus the safe area
insets.
</p>
<p>Dialogs have a default width of <code>medium</code>.</p>
<h3>Prevent scrim close</h3>
<p>
You can prevent the dialog from being closed by clicking the
scrim/overlay. This is allowed by default.
</p>
<h3>Header</h3>
<p>The header contains a title, a subtitle and action items.</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>header</code></td>
<td>The entire header area.</td>
</tr>
<tr>
<td><code>headerTitle</code></td>
<td>The header title text.</td>
</tr>
<tr>
<td><code>headerSubtitle</code></td>
<td>The header subtitle text.</td>
</tr>
<tr>
<td><code>headerActionItems</code></td>
<td>The header action items.</td>
</tr>
</tbody>
</table>
<h4>Header title</h4>
<p>The header title is a text string.</p>
<h4>Header subtitle</h4>
<p>The header subtitle is a text string.</p>
<h4>Header action items</h4>
<p>
The header action items usually containing icon buttons and/or menu
buttons.
</p>
<h3>Body</h3>
<p>The body is the content of the dialog.</p>
<h3>Footer</h3>
<p>The footer is the footer of the dialog.</p>
<p>
It is recommended to use the <code>ha-dialog-footer</code> component
for the footer and to style the buttons inside the footer as so:
</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Description</th>
<th>Variant to use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>secondaryAction</code></td>
<td>The secondary action button(s).</td>
<td><code>plain</code></td>
</tr>
<tr>
<td><code>primaryAction</code></td>
<td>The primary action button(s).</td>
<td><code>accent</code></td>
</tr>
</tbody>
</table>
<h2>Implementation</h2>
<h3>Example Usage</h3>
<pre><code>&lt;ha-wa-dialog
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
prevent-scrim-close
&gt;
&lt;div slot="headerActionItems"&gt;
&lt;ha-icon-button label="Settings" path="mdiCog"&gt;&lt;/ha-icon-button&gt;
&lt;ha-icon-button label="Help" path="mdiHelp"&gt;&lt;/ha-icon-button&gt;
&lt;/div&gt;
&lt;div&gt;Dialog content&lt;/div&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button data-dialog="close" slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Submit&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-wa-dialog&gt;</code></pre>
<h3>API</h3>
<p>
This component is based on the webawesome dialog component. Check the
<a
href="https://webawesome.com/docs/components/dialog/"
target="_blank"
rel="noopener noreferrer"
>webawesome documentation</a
>
for more details.
</p>
<h4>Attributes</h4>
<table>
<thead>
<tr>
<th>Attribute</th>
<th>Description</th>
<th>Default</th>
<th>Options</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>open</code></td>
<td>Controls the dialog open state.</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>
</tr>
<tr>
<td><code>width</code></td>
<td>Preferred dialog width preset.</td>
<td><code>medium</code></td>
<td>
<code>small</code>, <code>medium</code>, <code>large</code>,
<code>full</code>
</td>
</tr>
<tr>
<td><code>prevent-scrim-close</code></td>
<td>
Prevents closing the dialog by clicking the scrim/overlay.
</td>
<td><code>false</code></td>
<td><code>true</code></td>
</tr>
<tr>
<td><code>header-title</code></td>
<td>Header title text when no custom title slot is provided.</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>header-subtitle</code></td>
<td>
Header subtitle text when no custom subtitle slot is provided.
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>header-subtitle-position</code></td>
<td>Position of the subtitle relative to the title.</td>
<td><code>below</code></td>
<td><code>above</code>, <code>below</code></td>
</tr>
<tr>
<td><code>flexcontent</code></td>
<td>
Makes the dialog body a flex container for flexible layouts.
</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>
</tr>
</tbody>
</table>
<h4>CSS Custom Properties</h4>
<table>
<thead>
<tr>
<th>CSS Property</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--dialog-content-padding</code></td>
<td>Padding for dialog content sections.</td>
</tr>
<tr>
<td><code>--ha-dialog-show-duration</code></td>
<td>Show animation duration.</td>
</tr>
<tr>
<td><code>--ha-dialog-hide-duration</code></td>
<td>Hide animation duration.</td>
</tr>
<tr>
<td><code>--ha-dialog-surface-background</code></td>
<td>Dialog background color.</td>
</tr>
<tr>
<td><code>--ha-dialog-border-radius</code></td>
<td>Border radius of the dialog surface.</td>
</tr>
<tr>
<td><code>--dialog-z-index</code></td>
<td>Z-index for the dialog.</td>
</tr>
<tr>
<td><code>--dialog-surface-position</code></td>
<td>CSS position of the dialog surface.</td>
</tr>
<tr>
<td><code>--dialog-surface-margin-top</code></td>
<td>Top margin for the dialog surface.</td>
</tr>
</tbody>
</table>
<h4>Events</h4>
<table>
<thead>
<tr>
<th>Event</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>opened</code></td>
<td>Fired when the dialog is shown.</td>
</tr>
<tr>
<td><code>closed</code></td>
<td>Fired after the dialog is hidden.</td>
</tr>
</tbody>
</table>
</div>
`;
}
private _handleOpenDialog = (dialog: DialogType) => () => {
this._openDialog = dialog;
};
private _handleClosed = () => {
this._openDialog = false;
};
static styles = [
css`
:host {
display: block;
padding: var(--ha-space-4);
}
.content {
max-width: 1000px;
margin: 0 auto;
}
h1 {
margin-top: 0;
margin-bottom: var(--ha-space-2);
}
h2 {
margin-top: var(--ha-space-6);
margin-bottom: var(--ha-space-3);
}
h3,
h4 {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
p {
margin: var(--ha-space-2) 0;
line-height: 1.6;
}
.subtitle {
color: var(--secondary-text-color);
font-size: 1.1em;
margin-bottom: var(--ha-space-4);
}
table {
width: 100%;
border-collapse: collapse;
margin: var(--ha-space-3) 0;
}
th,
td {
text-align: left;
padding: var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
th {
font-weight: 500;
}
code {
background-color: var(--secondary-background-color);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
pre {
background-color: var(--secondary-background-color);
padding: var(--ha-space-3);
border-radius: 8px;
overflow-x: auto;
margin: var(--ha-space-3) 0;
}
pre code {
background-color: transparent;
padding: 0;
}
.buttons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
a {
color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-wa-dialog": DemoHaWaDialog;
}
}

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19", "@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19", "@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19", "@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.4", "@home-assistant/webawesome": "3.0.0-beta.6.ha.1",
"@lezer/highlight": "1.2.1", "@lezer/highlight": "1.2.1",
"@lit-labs/motion": "1.0.9", "@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6", "@lit-labs/observers": "2.0.6",
@@ -217,7 +217,7 @@
"terser-webpack-plugin": "5.3.14", "terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.46.0", "typescript-eslint": "8.45.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4", "vitest": "3.2.4",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",

View File

@@ -9,7 +9,7 @@
":semanticCommitsDisabled", ":semanticCommitsDisabled",
"group:monorepos", "group:monorepos",
"group:recommended", "group:recommended",
"security:minimumReleaseAgeNpm" "npm:unpublishSafe"
], ],
"enabledManagers": ["npm", "nvm"], "enabledManagers": ["npm", "nvm"],
"postUpdateOptions": ["yarnDedupeHighest"], "postUpdateOptions": ["yarnDedupeHighest"],

View File

@@ -0,0 +1,53 @@
/**
* Trigger a view transition if supported by the browser
* @param updateCallback - Callback function that updates the DOM
* @returns Promise that resolves when the transition is complete
*/
export const startViewTransition = async (
updateCallback: () => void | Promise<void>
): Promise<void> => {
// Check if View Transitions API is supported
if (
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
// Fallback: just run the update without transition
await updateCallback();
return;
}
// Start the view transition
const transition = document.startViewTransition(async () => {
await updateCallback();
});
try {
await transition.finished;
} catch (error) {
// Transitions can be skipped, which is fine
// eslint-disable-next-line no-console
console.debug("View transition skipped or failed:", error);
}
};
/**
* Helper to apply view transition on first render
* @param _element - The element to observe (unused, kept for API consistency)
* @param callback - Callback when element is first rendered
*/
export const applyViewTransitionOnLoad = (
_element: HTMLElement,
callback?: () => void
): void => {
if (!document.startViewTransition) {
callback?.();
return;
}
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
startViewTransition(() => {
callback?.();
});
});
};

View File

@@ -61,9 +61,3 @@ export const computeEntityEntryName = (
return name; return name;
}; };
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);

View File

@@ -1,104 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export type EntityNameItem =
| {
type: "entity" | "device" | "area" | "floor";
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
let items = ensureArray(name);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
// If all items are text, just join them
if (items.every((n) => n.type === "text")) {
return items.map((item) => item.text).join(separator);
}
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
// If entity uses device name, and device is not already included, replace it with device name
if (useDeviceName) {
const hasDevice = items.some((n) => n.type === "device");
if (!hasDevice) {
items = items.map((n) => (n.type === "entity" ? { type: "device" } : n));
}
}
const names = computeEntityNameList(
stateObj,
items,
entities,
devices,
areas,
floors
);
// If after processing there is only one name, return that
if (names.length === 1) {
return names[0] || "";
}
return names.filter((n) => n).join(separator);
};
export const computeEntityNameList = (
stateObj: HassEntity,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, area, floor } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
const names = name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
return floor ? computeFloorName(floor) : undefined;
case "text":
return item.text;
default:
return "";
}
});
return names;
};

View File

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

View File

@@ -1,12 +1,13 @@
import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import {
computeEntityNameDisplay,
type EntityNameItem,
type EntityNameOptions,
} from "../entity/compute_entity_name_display";
import type { LocalizeFunc } from "./localize"; import type { LocalizeFunc } from "./localize";
import { computeEntityName } from "../entity/compute_entity_name";
import { computeDeviceName } from "../entity/compute_device_name";
import { getEntityContext } from "../entity/context/get_entity_context";
import { computeAreaName } from "../entity/compute_area_name";
import { computeFloorName } from "../entity/compute_floor_name";
import { ensureArray } from "../array/ensure-array";
export type FormatEntityStateFunc = ( export type FormatEntityStateFunc = (
stateObj: HassEntity, stateObj: HassEntity,
@@ -26,8 +27,8 @@ export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = ( export type FormatEntityNameFunc = (
stateObj: HassEntity, stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[], type: EntityNameType | EntityNameType[],
options?: EntityNameOptions separator?: string
) => string; ) => string;
export const computeFormatFunctions = async ( export const computeFormatFunctions = async (
@@ -74,15 +75,45 @@ export const computeFormatFunctions = async (
), ),
formatEntityAttributeName: (stateObj, attribute) => formatEntityAttributeName: (stateObj, attribute) =>
computeAttributeNameDisplay(localize, stateObj, entities, attribute), computeAttributeNameDisplay(localize, stateObj, entities, attribute),
formatEntityName: (stateObj, name, options) => formatEntityName: (stateObj, type, separator = " ") => {
computeEntityNameDisplay( const types = ensureArray(type);
const namesList: (string | undefined)[] = [];
const { device, area, floor } = getEntityContext(
stateObj, stateObj,
name,
entities, entities,
devices, devices,
areas, areas,
floors, floors
options );
),
for (const t of types) {
switch (t) {
case "entity": {
namesList.push(computeEntityName(stateObj, entities, devices));
break;
}
case "device": {
if (device) {
namesList.push(computeDeviceName(device));
}
break;
}
case "area": {
if (area) {
namesList.push(computeAreaName(area));
}
break;
}
case "floor": {
if (floor) {
namesList.push(computeFloorName(floor));
}
break;
}
}
}
return namesList.filter((name) => name !== undefined).join(separator);
},
}; };
}; };

View File

@@ -1,9 +1,6 @@
import { expose } from "comlink"; import { expose } from "comlink";
import Fuse from "fuse.js"; import { stringCompare, ipCompare } from "../../common/string/compare";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics"; import { stripDiacritics } from "../../common/string/strip-diacritics";
import { HaFuse } from "../../resources/fuse";
import type { import type {
ClonedDataTableColumnData, ClonedDataTableColumnData,
DataTableRowData, DataTableRowData,
@@ -11,48 +8,29 @@ import type {
SortingDirection, SortingDirection,
} from "./ha-data-table"; } from "./ha-data-table";
const fuseIndex = memoizeOne(
(data: DataTableRowData[], columns: SortableColumnContainer) => {
const searchKeys = new Set<string>();
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
column.filterKey
? `${column.valueColumn || key}.${column.filterKey}`
: key
);
}
});
return Fuse.createIndex([...searchKeys], data);
}
);
const filterData = ( const filterData = (
data: DataTableRowData[], data: DataTableRowData[],
columns: SortableColumnContainer, columns: SortableColumnContainer,
filter: string filter: string
) => { ) => {
filter = stripDiacritics(filter.toLowerCase()); filter = stripDiacritics(filter.toLowerCase());
return data.filter((row) =>
Object.entries(columns).some((columnEntry) => {
const [key, column] = columnEntry;
if (column.filterable) {
const value = String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
);
if (filter === "") { if (stripDiacritics(value).toLowerCase().includes(filter)) {
return data; return true;
} }
}
const index = fuseIndex(data, columns); return false;
})
const fuse = new HaFuse(
data,
{ shouldSort: false, minMatchCharLength: 1 },
index
); );
const searchResults = fuse.multiTermsSearch(filter);
if (searchResults) {
return searchResults.map((result) => result.item);
}
return data;
}; };
const sortData = ( const sortData = (

View File

@@ -5,18 +5,24 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name"; import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { import {
getDevices, getDeviceEntityDisplayLookup,
type DevicePickerItem, type DeviceEntityDisplayLookup,
type DeviceRegistryEntry, type DeviceRegistryEntry,
} from "../../data/device_registry"; } from "../../data/device_registry";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url"; import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker"; import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
export type HaDevicePickerDeviceFilterFunc = ( export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry device: DeviceRegistryEntry
@@ -24,6 +30,11 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
@customElement("ha-device-picker") @customElement("ha-device-picker")
export class HaDevicePicker extends LitElement { export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -93,8 +104,6 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {}; @state() private _configEntryLookup: Record<string, ConfigEntry> = {};
private _getDevicesMemoized = memoizeOne(getDevices);
protected firstUpdated(_changedProperties: PropertyValues): void { protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties); super.firstUpdated(_changedProperties);
this._loadConfigEntries(); this._loadConfigEntries();
@@ -108,18 +117,162 @@ export class HaDevicePicker extends LitElement {
} }
private _getItems = () => private _getItems = () =>
this._getDevicesMemoized( this._getDevices(
this.hass, this.hass.devices,
this.hass.entities,
this._configEntryLookup, this._configEntryLookup,
this.includeDomains, this.includeDomains,
this.excludeDomains, this.excludeDomains,
this.includeDeviceClasses, this.includeDeviceClasses,
this.deviceFilter, this.deviceFilter,
this.entityFilter, this.entityFilter,
this.excludeDevices, this.excludeDevices
this.value
); );
private _getDevices = memoizeOne(
(
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
configEntryLookup: Record<string, ConfigEntry>,
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeDevices: this["excludeDevices"]
): DevicePickerItem[] => {
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
let inputDevices = devices.filter(
(device) => device.id === this.value || !device.disabled_by
);
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))
);
});
}
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))
);
});
}
if (excludeDevices) {
inputDevices = inputDevices.filter(
(device) => !excludeDevices!.includes(device.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)
);
});
});
}
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
}
if (deviceFilter) {
inputDevices = inputDevices.filter(
(device) =>
// We always want to include the device of the current value
device.id === this.value || deviceFilter!(device)
);
}
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
const deviceName = computeDeviceNameDisplay(
device,
this.hass,
deviceEntityLookup[device.id]
);
const { area } = getDeviceContext(device, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
const domainName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return {
id: device.id,
label: "",
primary:
deviceName ||
this.hass.localize("ui.components.device-picker.unnamed_device"),
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
sorting_label: deviceName || "zzz",
};
});
return outputDevices;
}
);
private _valueRenderer = memoizeOne( private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => { (configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value; const deviceId = value;

View File

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

View File

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

View File

@@ -1,493 +0,0 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDrag, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-sortable";
interface EntityNameOption {
primary: string;
secondary?: string;
value: string;
}
const rowRenderer: ComboBoxLitRenderer<EntityNameOption> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
@customElement("ha-entity-name-picker")
export class HaEntityNamePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property({ attribute: false }) public value?:
| string
| EntityNameItem
| EntityNameItem[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query(".container", true) private _container?: HTMLDivElement;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
private _editIndex?: number;
private _validOptions = memoizeOne((entityId?: string) => {
const options = new Set<string>();
if (!entityId) {
return options;
}
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return options;
}
options.add("entity");
const context = getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
if (context.device) options.add("device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
});
private _getOptions = memoizeOne((entityId?: string) => {
if (!entityId) {
return [];
}
const options = this._validOptions(entityId);
const items = (
["entity", "device", "area", "floor"] as const
).map<EntityNameOption>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = options.has(name);
const primary = this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}`
);
const secondary =
stateObj && isValid
? this.hass.formatEntityName(stateObj, { type: name })
: this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
) || "-";
return {
primary,
secondary,
value: name,
};
});
return items;
});
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
protected render() {
const value = this._value;
const options = this._getOptions(this.entityId);
const validOptions = this._validOptions(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid =
item.type === "text" || validOptions.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div>
`;
}
private _onClosed(ev) {
ev.stopPropagation();
this._opened = false;
this._editIndex = undefined;
}
private async _onOpened(ev) {
if (!this._opened) {
return;
}
ev.stopPropagation();
this._opened = true;
await this._comboBox?.focus();
await this._comboBox?.open();
}
private async _addItem(ev) {
ev.stopPropagation();
this._opened = true;
}
private async _editItem(ev) {
ev.stopPropagation();
const idx = parseInt(ev.currentTarget.dataset.idx, 10);
this._editIndex = idx;
this._opened = true;
}
private get _value(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
return [{ type: "text", text: value } as const];
}
return value ? ensureArray(value) : [];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return [];
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _openedChanged(ev: ValueChangedEvent<boolean>) {
const open = ev.detail.value;
if (open) {
const options = this._comboBox.items || [];
const initialItem =
this._editIndex != null ? this._value[this._editIndex] : undefined;
const initialValue = initialItem
? initialItem.type === "text"
? initialItem.text
: initialItem.type
: "";
const filteredItems = this._filterSelectedOptions(options, initialValue);
this._comboBox.filteredItems = filteredItems;
this._comboBox.setInputValue(initialValue);
} else {
this._opened = false;
}
}
private _filterSelectedOptions = (
options: EntityNameOption[],
current?: string
) => {
const value = this._value;
const types = value.map((item) => item.type) as string[];
const filteredOptions = options.filter(
(option) =>
!UNIQUE_TYPES.has(option.value) ||
!types.includes(option.value) ||
option.value === current
);
return filteredOptions;
};
private _filterChanged(ev: ValueChangedEvent<string>) {
const input = ev.detail.value;
const filter = input?.toLowerCase() || "";
const options = this._comboBox.items || [];
const currentItem =
this._editIndex != null ? this._value[this._editIndex] : undefined;
const currentValue = currentItem
? currentItem.type === "text"
? currentItem.text
: currentItem.type
: "";
this._comboBox.filteredItems = this._filterSelectedOptions(
options,
currentValue
);
if (!filter) {
return;
}
const fuseOptions: IFuseOptions<EntityNameOption> = {
keys: ["primary", "secondary", "value"],
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
};
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
const filteredItems = fuse.search(filter).map((result) => result.item);
this._comboBox.filteredItems = filteredItems;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private async _removeItem(ev) {
ev.stopPropagation();
const value = [...this._value];
const idx = parseInt(ev.target.dataset.idx, 10);
value.splice(idx, 1);
this._setValue(value);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || value === "") {
return;
}
const item: EntityNameItem = KNOWN_TYPES.has(value as any)
? { type: value as EntityNameType }
: { type: "text", text: value };
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
} else {
newValue.push(item);
}
this._setValue(newValue);
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-name-picker": HaEntityNamePicker;
}
}

View File

@@ -1,17 +1,14 @@
import { mdiPlus, mdiShape } from "@mdi/js"; import { mdiPlus, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit"; import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl"; 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 { domainToName } from "../../data/integration";
import { import {
isHelperDomain, isHelperDomain,
@@ -22,11 +19,21 @@ import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-generic-picker"; import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box"; import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field"; import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___"; const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker") @customElement("ha-entity-picker")
@@ -137,14 +144,9 @@ export class HaEntityPicker extends LitElement {
`; `;
} }
const [entityName, deviceName, areaName] = computeEntityNameList( const entityName = this.hass.formatEntityName(stateObj, "entity");
stateObj, const deviceName = this.hass.formatEntityName(stateObj, "device");
[{ type: "entity" }, { type: "device" }, { type: "area" }], const areaName = this.hass.formatEntityName(stateObj, "area");
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(this.hass); const isRTL = computeRTL(this.hass);
@@ -247,10 +249,8 @@ export class HaEntityPicker extends LitElement {
} }
); );
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getItems = () => private _getItems = () =>
this._getEntitiesMemoized( this._getEntities(
this.hass, this.hass,
this.includeDomains, this.includeDomains,
this.excludeDomains, this.excludeDomains,
@@ -258,10 +258,125 @@ export class HaEntityPicker extends LitElement {
this.includeDeviceClasses, this.includeDeviceClasses,
this.includeUnitOfMeasurement, this.includeUnitOfMeasurement,
this.includeEntities, 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() { protected render() {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??

View File

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

View File

@@ -6,7 +6,6 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
@@ -200,7 +199,7 @@ export class HaStatisticPicker extends LitElement {
}); });
} }
const isRTL = computeRTL(hass); const isRTL = computeRTL(this.hass);
const output: StatisticComboBoxItem[] = []; const output: StatisticComboBoxItem[] = [];
@@ -257,15 +256,9 @@ export class HaStatisticPicker extends LitElement {
const id = meta.statistic_id; const id = meta.statistic_id;
const friendlyName = computeStateName(stateObj); // Keep this for search const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = hass.formatEntityName(stateObj, "entity");
const [entityName, deviceName, areaName] = computeEntityNameList( const deviceName = hass.formatEntityName(stateObj, "device");
stateObj, const areaName = hass.formatEntityName(stateObj, "area");
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id; const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
@@ -338,14 +331,9 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId]; const stateObj = this.hass.states[statisticId];
if (stateObj) { if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList( const entityName = this.hass.formatEntityName(stateObj, "entity");
stateObj, const deviceName = this.hass.formatEntityName(stateObj, "device");
[{ type: "entity" }, { type: "device" }, { type: "area" }], const areaName = this.hass.formatEntityName(stateObj, "area");
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(this.hass); const isRTL = computeRTL(this.hass);

View File

@@ -8,13 +8,21 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name"; import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { stringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl"; 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 { import {
getAreasAndFloors, getFloorAreaLookup,
type AreaFloorValue, type FloorRegistryEntry,
type FloorComboBoxItem, } from "../data/floor_registry";
} from "../data/area_floor";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
@@ -22,12 +30,24 @@ import "./ha-floor-icon";
import "./ha-generic-picker"; import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker"; import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field"; import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./ha-tree-indicator"; import "./ha-tree-indicator";
const SEPARATOR = "________"; 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") @customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends LitElement { export class HaAreaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -134,6 +154,243 @@ 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> = ( private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item, item,
{ index }, { index },
@@ -188,16 +445,12 @@ export class HaAreaFloorPicker extends LitElement {
`; `;
}; };
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private _getItems = () => private _getItems = () =>
this._getAreasAndFloorsMemoized( this._getAreasAndFloors(
this.hass.states,
this.hass.floors, this.hass.floors,
this.hass.areas, this.hass.areas,
this.hass.devices, this.hass.devices,
this.hass.entities, this.hass.entities,
this._formatValue,
this.includeDomains, this.includeDomains,
this.excludeDomains, this.excludeDomains,
this.includeDeviceClasses, this.includeDeviceClasses,

View File

@@ -107,7 +107,7 @@ export class HaAreaPicker extends LitElement {
`; `;
} }
const { floor } = getAreaContext(area, this.hass.floors); const { floor } = getAreaContext(area, this.hass);
const areaName = area ? computeAreaName(area) : undefined; const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined; const floorName = floor ? computeFloorName(floor) : undefined;
@@ -279,7 +279,7 @@ export class HaAreaPicker extends LitElement {
} }
const items = outputAreas.map<PickerComboBoxItem>((area) => { const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass.floors); const { floor } = getAreaContext(area, this.hass);
const floorName = floor ? computeFloorName(floor) : undefined; const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area); const areaName = computeAreaName(area);
return { return {

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { css, html, LitElement, type PropertyValues } from "lit"; import { css, html, LitElement, type PropertyValues } from "lit";
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -8,9 +8,6 @@ export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
export class HaBottomSheet extends LitElement { export class HaBottomSheet extends LitElement {
@property({ type: Boolean }) public open = false; @property({ type: Boolean }) public open = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@state() private _drawerOpen = false; @state() private _drawerOpen = false;
private _handleAfterHide() { private _handleAfterHide() {
@@ -44,19 +41,16 @@ export class HaBottomSheet extends LitElement {
static styles = css` static styles = css`
wa-drawer { wa-drawer {
--wa-color-surface-raised: transparent; --wa-color-surface-raised: var(
--ha-bottom-sheet-surface-background,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
--spacing: 0; --spacing: 0;
--size: var(--ha-bottom-sheet-height, auto); --size: auto;
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
} }
wa-drawer::part(dialog) { wa-drawer::part(dialog) {
max-height: var(--ha-bottom-sheet-max-height, 90vh);
align-items: center;
}
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
width: 100%;
border-top-left-radius: var( border-top-left-radius: var(
--ha-bottom-sheet-border-radius, --ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
@@ -65,19 +59,10 @@ export class HaBottomSheet extends LitElement {
--ha-bottom-sheet-border-radius, --ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
); );
background-color: var( max-height: 90vh;
--ha-bottom-sheet-surface-background, padding-bottom: var(--safe-area-inset-bottom);
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), padding-left: var(--safe-area-inset-left);
); padding-right: var(--safe-area-inset-right);
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
:host([flexcontent]) wa-drawer::part(body) {
display: flex;
} }
`; `;
} }

View File

@@ -1,52 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant dialog footer component
*
* @element ha-dialog-footer
* @extends {LitElement}
*
* @summary
* A simple footer container for dialog actions,
* typically used as the `footer` slot in `ha-wa-dialog`.
*
* @slot primaryAction - Primary action button(s), aligned to the end.
* @slot secondaryAction - Secondary action button(s), placed before the primary action.
*
* @remarks
* **Button Styling Guidance:**
* - `primaryAction` slot: Use `variant="accent"`
* - `secondaryAction` slot: Use `variant="plain"`
*/
@customElement("ha-dialog-footer")
export class HaDialogFooter extends LitElement {
protected render() {
return html`
<footer>
<slot name="secondaryAction"></slot>
<slot name="primaryAction"></slot>
</footer>
`;
}
static get styles() {
return [
css`
footer {
display: flex;
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-footer": HaDialogFooter;
}
}

View File

@@ -1,20 +1,9 @@
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement } from "lit/decorators";
@customElement("ha-dialog-header") @customElement("ha-dialog-header")
export class HaDialogHeader extends LitElement { export class HaDialogHeader extends LitElement {
@property({ type: String, attribute: "subtitle-position" })
public subtitlePosition: "above" | "below" = "below";
protected render() { protected render() {
const titleSlot = html`<div class="header-title">
<slot name="title"></slot>
</div>`;
const subtitleSlot = html`<div class="header-subtitle">
<slot name="subtitle"></slot>
</div>`;
return html` return html`
<header class="header"> <header class="header">
<div class="header-bar"> <div class="header-bar">
@@ -22,9 +11,12 @@ export class HaDialogHeader extends LitElement {
<slot name="navigationIcon"></slot> <slot name="navigationIcon"></slot>
</section> </section>
<section class="header-content"> <section class="header-content">
${this.subtitlePosition === "above" <div class="header-title">
? html`${subtitleSlot}${titleSlot}` <slot name="title"></slot>
: html`${titleSlot}${subtitleSlot}`} </div>
<div class="header-subtitle">
<slot name="subtitle"></slot>
</div>
</section> </section>
<section class="header-action-items"> <section class="header-action-items">
<slot name="actionItems"></slot> <slot name="actionItems"></slot>
@@ -48,7 +40,7 @@ export class HaDialogHeader extends LitElement {
.header-bar { .header-bar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: flex-start;
padding: 4px; padding: 4px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -61,17 +53,13 @@ export class HaDialogHeader extends LitElement {
white-space: nowrap; white-space: nowrap;
} }
.header-title { .header-title {
height: var(
--ha-dialog-header-title-height,
calc(var(--ha-font-size-xl) + 4px)
);
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-normal);
} }
.header-subtitle { .header-subtitle {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal); line-height: 20px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
@media all and (min-width: 450px) and (min-height: 500px) { @media all and (min-width: 450px) and (min-height: 500px) {

View File

@@ -121,7 +121,7 @@ export class HaDialog extends DialogBase {
position: var(--dialog-surface-position, relative); position: var(--dialog-surface-position, relative);
top: var(--dialog-surface-top); top: var(--dialog-surface-top);
margin-top: var(--dialog-surface-margin-top); margin-top: var(--dialog-surface-margin-top);
min-width: var(--mdc-dialog-min-width, auto); min-width: var(--mdc-dialog-min-width, 100vw);
min-height: var(--mdc-dialog-min-height, auto); min-height: var(--mdc-dialog-min-height, auto);
border-radius: var( border-radius: var(
--ha-dialog-border-radius, --ha-dialog-border-radius,
@@ -133,13 +133,25 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
); );
padding: var(--dialog-surface-padding);
} }
:host([flexContent]) .mdc-dialog .mdc-dialog__content { :host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media all and (max-width: 450px), all and (max-height: 500px) {
.mdc-dialog .mdc-dialog__surface {
min-height: 100vh;
min-height: 100svh;
max-height: 100vh;
max-height: 100svh;
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);
}
}
.header_title { .header_title {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -49,7 +49,6 @@ export class HaExpansionPanel extends LitElement {
tabindex=${this.noCollapse ? -1 : 0} tabindex=${this.noCollapse ? -1 : 0}
aria-expanded=${this.expanded} aria-expanded=${this.expanded}
aria-controls="sect1" aria-controls="sect1"
part="summary"
> >
${this.leftChevron ? chevronIcon : nothing} ${this.leftChevron ? chevronIcon : nothing}
<slot name="leading-icon"></slot> <slot name="leading-icon"></slot>
@@ -171,11 +170,6 @@ export class HaExpansionPanel extends LitElement {
margin-left: 8px; margin-left: 8px;
margin-inline-start: 8px; margin-inline-start: 8px;
margin-inline-end: initial; margin-inline-end: initial;
border-radius: var(--ha-border-radius-circle);
}
#summary:focus-visible ha-svg-icon.summary-icon {
background-color: var(--ha-color-fill-neutral-normal-active);
} }
:host([left-chevron]) .summary-icon, :host([left-chevron]) .summary-icon,

View File

@@ -61,7 +61,6 @@ export class HaFormString extends LitElement implements HaFormElement {
.required=${this.schema.required} .required=${this.schema.required}
.autoValidate=${this.schema.required} .autoValidate=${this.schema.required}
.name=${this.schema.name} .name=${this.schema.name}
.autofocus=${this.schema.autofocus}
.autocomplete=${this.schema.autocomplete} .autocomplete=${this.schema.autocomplete}
.suffix=${this.isPassword .suffix=${this.isPassword
? // reserve some space for the icon. ? // reserve some space for the icon.

View File

@@ -105,11 +105,6 @@ export class HaForm extends LitElement implements HaFormElement {
} }
} }
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div class="root" part="root"> <div class="root" part="root">

View File

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

View File

@@ -5,10 +5,16 @@ import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import type { LabelRegistryEntry } from "../data/label_registry"; import type { LabelRegistryEntry } from "../data/label_registry";
import { import {
createLabelRegistryEntry, createLabelRegistryEntry,
getLabels,
subscribeLabelRegistry, subscribeLabelRegistry,
} from "../data/label_registry"; } from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
@@ -131,22 +137,201 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
} }
); );
private _getLabelsMemoized = memoizeOne(getLabels); private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined,
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeLabels: this["excludeLabels"]
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
private _getItems = () => { const devices = Object.values(haDevices);
if (!this._labels || this._labels.length === 0) { const entities = Object.values(haEntities);
return [
{ let deviceEntityLookup: DeviceEntityDisplayLookup = {};
id: NO_LABELS, let inputDevices: DeviceRegistryEntry[] | undefined;
primary: this.hass.localize("ui.components.label-picker.no_labels"), let inputEntities: EntityRegistryDisplayEntry[] | undefined;
icon_path: mdiLabel,
}, if (
]; includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels.length > 0);
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 outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = haAreas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
}));
return items;
} }
);
return this._getLabelsMemoized( private _getItems = () =>
this.hass, this._getLabels(
this._labels, this._labels,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains, this.includeDomains,
this.excludeDomains, this.excludeDomains,
this.includeDeviceClasses, this.includeDeviceClasses,
@@ -154,7 +339,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter, this.entityFilter,
this.excludeLabels this.excludeLabels
); );
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) { if (!labels) {

View File

@@ -1,50 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { EntityNameSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-name-picker";
@customElement("ha-selector-entity_name")
export class HaSelectorEntityName extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: EntityNameSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
entity?: string;
};
protected render() {
const value = this.value ?? this.selector.entity_name?.default_name;
return html`
<ha-entity-name-picker
.hass=${this.hass}
.entityId=${this.selector.entity_name?.entity_id ||
this.context?.entity}
.value=${value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-entity-name-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-entity_name": HaSelectorEntityName;
}
}

View File

@@ -29,7 +29,6 @@ const LOAD_ELEMENTS = {
device: () => import("./ha-selector-device"), device: () => import("./ha-selector-device"),
duration: () => import("./ha-selector-duration"), duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"), entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
statistic: () => import("./ha-selector-statistic"), statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"), file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"), floor: () => import("./ha-selector-floor"),

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ export class HaTooltip extends Tooltip {
css` css`
:host { :host {
--wa-tooltip-background-color: var(--secondary-background-color); --wa-tooltip-background-color: var(--secondary-background-color);
--wa-tooltip-content-color: var(--primary-text-color); --wa-tooltip-color: var(--primary-text-color);
--wa-tooltip-font-family: var( --wa-tooltip-font-family: var(
--ha-tooltip-font-family, --ha-tooltip-font-family,
var(--ha-font-family-body) var(--ha-font-family-body)

View File

@@ -1,320 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import "./ha-dialog-header";
import "./ha-icon-button";
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
export type DialogWidth = "small" | "medium" | "large" | "full";
/**
* Home Assistant dialog component
*
* @element ha-wa-dialog
* @extends {LitElement}
*
* @summary
* A stylable dialog built using the `wa-dialog` component, providing a standardized header (ha-dialog-header),
* body, and footer (preferably using `ha-dialog-footer`) with slots
*
* You can open and close the dialog declaratively by using the `data-dialog="close"` attribute.
* @see https://webawesome.com/docs/components/dialog/#opening-and-closing-dialogs-declaratively
*
* @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog content body.
* @slot footer - Dialog footer content.
*
* @csspart dialog - The dialog surface.
* @csspart header - The header container.
* @csspart body - The scrollable body container.
* @csspart footer - The footer container.
*
* @cssprop --dialog-content-padding - Padding for the dialog content sections.
* @cssprop --ha-dialog-show-duration - Show animation duration.
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
* @cssprop --ha-dialog-surface-background - Dialog background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
* @cssprop --dialog-z-index - Z-index for the dialog.
* @cssprop --dialog-surface-position - CSS position of the dialog surface.
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
*
* @attr {boolean} open - Controls the dialog open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text when no custom title slot is provided.
* @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts.
*
* @event opened - Fired when the dialog is shown.
* @event closed - Fired after the dialog is hidden.
*
* @remarks
* **Focus Management:**
* To automatically focus an element when the dialog opens, add the `autofocus` attribute to it.
* Components with `delegatesFocus: true` (like `ha-form`) will forward focus to their first focusable child.
* Example: `<ha-form autofocus .schema=${schema}></ha-form>`
*
* @see https://github.com/home-assistant/frontend/issues/27143
*/
@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: "width" })
public width: DialogWidth = "medium";
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@property({ type: String, attribute: "header-title" })
public headerTitle = "";
@property({ type: String, attribute: "header-subtitle" })
public headerSubtitle = "";
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@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")) {
this._open = this.open;
}
}
protected render() {
return html`
<wa-dialog
.open=${this._open}
.lightDismiss=${!this.preventScrimClose}
without-header
@wa-show=${this._handleShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="header">
<ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle
? html`<span slot="title" class="title">
${this.headerTitle}
</span>`
: nothing}
${this.headerSubtitle
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: nothing}
<slot name="headerActionItems" 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 = async () => {
this._open = true;
fireEvent(this, "opened");
await this.updateComplete;
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
};
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
};
public disconnectedCallback(): void {
super.disconnectedCallback();
this._open = false;
}
static styles = [
haStyleScrollbar,
css`
wa-dialog {
--full-width: var(
--ha-dialog-width-full,
min(
95vw,
calc(
100vw - var(--safe-area-inset-left, var(--ha-space-0)) - var(
--safe-area-inset-right,
var(--ha-space-0)
)
)
)
);
--width: var(--ha-dialog-width-md, min(580px, var(--full-width)));
--spacing: var(--dialog-content-padding, var(--ha-space-6));
--show-duration: var(--ha-dialog-show-duration, 200ms);
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
--ha-dialog-surface-background: var(
--card-background-color,
var(--ha-color-surface-default)
);
--wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
--wa-panel-border-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-3xl)
);
max-width: var(--ha-dialog-max-width, 100vw);
max-width: var(--ha-dialog-max-width, 100svw);
}
:host([width="small"]) wa-dialog {
--width: var(--ha-dialog-width-sm, min(320px, var(--full-width)));
}
:host([width="large"]) wa-dialog {
--width: var(--ha-dialog-width-lg, min(720px, var(--full-width)));
}
:host([width="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: var(
--ha-dialog-max-height,
calc(100% - var(--ha-space-20))
);
position: var(--dialog-surface-position, relative);
margin-top: var(--dialog-surface-margin-top, auto);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
:host {
--ha-dialog-border-radius: var(--ha-space-0);
}
wa-dialog {
--full-width: var(--ha-dialog-width-full, 100vw);
}
wa-dialog::part(dialog) {
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100svh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100svh);
padding-top: var(--safe-area-inset-top, var(--ha-space-0));
padding-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
padding-left: var(--safe-area-inset-left, var(--ha-space-0));
padding-right: var(--safe-area-inset-right, var(--ha-space-0));
}
}
.header-title-container {
display: flex;
align-items: center;
}
.header-title {
margin: 0;
margin-bottom: 0;
color: var(
--ha-dialog-header-title-color,
var(--ha-color-on-surface-default, var(--primary-text-color))
);
font-size: var(
--ha-dialog-header-title-font-size,
var(--ha-font-size-2xl)
);
line-height: var(
--ha-dialog-header-title-line-height,
var(--ha-line-height-condensed)
);
font-weight: var(
--ha-dialog-header-title-font-weight,
var(--ha-font-weight-normal)
);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--ha-space-3);
}
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, var(--ha-space-6))
var(--dialog-content-padding, var(--ha-space-6))
var(--dialog-content-padding, var(--ha-space-6));
overflow: auto;
flex-grow: 1;
}
:host([flexcontent]) .body {
max-width: 100%;
display: flex;
flex-direction: column;
}
wa-dialog::part(footer) {
padding: var(--ha-space-0);
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-wa-dialog": HaWaDialog;
}
interface HASSDomEvents {
opened: undefined;
closed: undefined;
}
}

View File

@@ -1,104 +0,0 @@
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-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
class DialogTargetDetails extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: TargetDetailsDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
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;
}
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: var(--ha-space-2) var(--ha-space-6)
max(var(--safe-area-inset-bottom, var(--ha-space-0)), var(--ha-space-8));
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: var(--ha-space-0);
min-width: 100%;
min-height: 100%;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-target-details": DialogTargetDetails;
}
}

View File

@@ -1,28 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
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

@@ -1,107 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
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";
@customElement("ha-target-picker-item-group")
export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetTypeFloorless;
@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 TargetTypeFloorless}
.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: var(--ha-space-0);
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
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: var(--ha-space-0);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-item-group": HaTargetPickerItemGroup;
}
}

View File

@@ -1,690 +0,0 @@
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 type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
extractFromTarget,
type ExtractFromTargetResult,
type ExtractFromTargetResultReferenced,
type TargetType,
} 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";
@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: "sub-entry", reflect: true })
public subEntry = false;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@property({ attribute: false })
public parentEntries?: ExtractFromTargetResultReferenced;
@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 devicesInAreas = [] as string[];
const rows1Entries =
nextType === "entity"
? undefined
: rows1.map((rowItem) => {
const nextEntries = {
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[]);
devicesInAreas.push(...nextEntries.referenced_devices);
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 entityRows =
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
)
: [];
const deviceRows =
this.type === "label" && entries
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id].labels.includes(this.itemId)
)
: [];
const deviceRowsEntries =
deviceRows.length === 0
? undefined
: deviceRows.map((device_id) => ({
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities:
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
) || ([] as string[]),
}));
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
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></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: var(--ha-space-0);
--md-list-item-bottom-space: var(--ha-space-0);
--md-list-item-leading-space: var(--ha-space-2);
--md-list-item-trailing-space: var(--ha-space-2);
--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: var(--ha-space-12);
}
.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: var(--ha-space-5);
}
.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);
}
button.link {
text-decoration: none;
color: var(--primary-color);
}
button.link:hover,
button.link:focus {
text-decoration: underline;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-item-row": HaTargetPickerItemRow;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,354 +0,0 @@
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 { TargetType } from "../../data/target";
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";
@customElement("ha-target-picker-value-chip")
export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() 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: var(--ha-space-4);
height: var(--ha-space-4);
--mdc-icon-size: 14px;
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1) !important;
margin-inline-end: calc(-1 * var(--ha-space-1)) !important;
direction: var(--direction);
}
.mdc-chip__icon--leading {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 20px;
border-radius: var(--ha-border-radius-circle);
padding: 6px;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: var(--ha-space-1) !important;
direction: var(--direction);
}
.expand-btn {
margin-right: var(--ha-space-0);
margin-inline-end: var(--ha-space-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-value-chip": HaTargetPickerValueChip;
}
}

View File

@@ -1,259 +0,0 @@
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[]
): 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 = 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 = 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 = states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = 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 unassignedAreas = 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(
...unassignedAreas.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],
};
})
);
return items;
};

View File

@@ -1,20 +1,12 @@
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name"; import { computeStateName } from "../common/entity/compute_state_name";
import { getDeviceContext } from "../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } 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 { HomeAssistant } from "../types";
import type { ConfigEntry } from "./config_entries"; import type { ConfigEntry } from "./config_entries";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { import type {
EntityRegistryDisplayEntry, EntityRegistryDisplayEntry,
EntityRegistryEntry, EntityRegistryEntry,
} from "./entity_registry"; } from "./entity_registry";
import type { EntitySources } from "./entity_sources"; import type { EntitySources } from "./entity_sources";
import { domainToName } from "./integration";
import type { RegistryEntry } from "./registry"; import type { RegistryEntry } from "./registry";
export { export {
@@ -171,147 +163,3 @@ export const getDeviceIntegrationLookup = (
} }
return deviceIntegrations; return deviceIntegrations;
}; };
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
export const getDevices = (
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string
): DevicePickerItem[] => {
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
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))
);
});
}
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))
);
});
}
if (excludeDevices) {
inputDevices = inputDevices.filter(
(device) => !excludeDevices!.includes(device.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 = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
}
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
}
if (deviceFilter) {
inputDevices = inputDevices.filter(
(device) =>
// We always want to include the device of the current value
device.id === value || deviceFilter!(device)
);
}
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
const deviceName = computeDeviceNameDisplay(
device,
hass,
deviceEntityLookup[device.id]
);
const { area } = getDeviceContext(device, hass);
const areaName = area ? computeAreaName(area) : undefined;
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
const domainName = domain ? domainToName(hass.localize, domain) : undefined;
return {
id: device.id,
label: "",
primary:
deviceName ||
hass.localize("ui.components.device-picker.unnamed_device"),
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
sorting_label: deviceName || "zzz",
};
});
return outputDevices;
};

View File

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

View File

@@ -1,17 +1,12 @@
import type { Connection, HassEntity } from "home-assistant-js-websocket"; import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store"; import type { Store } from "home-assistant-js-websocket/dist/store";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeStateName } from "../common/entity/compute_state_name"; import { computeStateName } from "../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { debounce } from "../common/util/debounce"; import { debounce } from "../common/util/debounce";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import { domainToName } from "./integration";
import type { LightColor } from "./light"; import type { LightColor } from "./light";
import type { RegistryEntry } from "./registry"; import type { RegistryEntry } from "./registry";
@@ -329,122 +324,3 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids", type: "config/entity_registry/get_automatic_entity_ids",
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[] => {
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))
);
}
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(hass);
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 === 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 === 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 === value || (item.stateObj && entityFilter!(item.stateObj))
);
}
return items;
};

View File

@@ -1,20 +1,9 @@
import { mdiLabel } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket"; import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store"; import type { Store } from "home-assistant-js-websocket/dist/store";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce"; import { debounce } from "../common/util/debounce";
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 { HomeAssistant } from "../types";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import type { RegistryEntry } from "./registry"; import type { RegistryEntry } from "./registry";
export interface LabelRegistryEntry extends RegistryEntry { export interface LabelRegistryEntry extends RegistryEntry {
@@ -99,178 +88,3 @@ export const deleteLabelRegistryEntry = (
type: "config/label_registry/delete", type: "config/label_registry/delete",
label_id: labelId, label_id: labelId,
}); });
export const getLabels = (
hass: HomeAssistant,
labels?: LabelRegistryEntry[],
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeLabels?: string[]
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [];
}
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
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.labels.length > 0);
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 = 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 = 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 = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = hass.areas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
}));
return items;
};

View File

@@ -39,6 +39,8 @@ import type { HomeAssistant, TranslationDict } from "../types";
import { isUnavailableState } from "./entity"; import { isUnavailableState } from "./entity";
import { isTTSMediaSource } from "./tts"; import { isTTSMediaSource } from "./tts";
import { generateEntityFilter } from "../common/entity/entity_filter";
interface MediaPlayerEntityAttributes extends HassEntityAttributeBase { interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
media_content_id?: string; media_content_id?: string;
media_content_type?: string; media_content_type?: string;
@@ -522,3 +524,33 @@ export const mediaPlayerJoin = (
export const mediaPlayerUnjoin = (hass: HomeAssistant, entity_id: string) => export const mediaPlayerUnjoin = (hass: HomeAssistant, entity_id: string) =>
hass.callService("media_player", "unjoin", {}, { entity_id }); hass.callService("media_player", "unjoin", {}, { entity_id });
/**
* Compute active media player states in a specific area.
* @param hass Home Assistant object
* @param areaId Area ID to filter media players by
* @returns Array of playing media player entities
*/
export const computeActiveAreaMediaStates = (
hass: HomeAssistant,
areaId: string
): MediaPlayerEntity[] => {
const area = hass.areas[areaId];
if (!area) {
return [];
}
// Get all media_player entities in this area
const mediaFilter = generateEntityFilter(hass, {
area: areaId,
domain: "media_player",
});
const mediaEntities = Object.keys(hass.entities).filter(mediaFilter);
return mediaEntities
.map((entityId) => hass.states[entityId] as MediaPlayerEntity | undefined)
.filter(
(stateObj): stateObj is MediaPlayerEntity => stateObj?.state === "playing"
);
};

View File

@@ -95,9 +95,7 @@ export interface StatisticsValidationResultUnitsChanged {
data: { data: {
statistic_id: string; statistic_id: string;
state_unit: string; state_unit: string;
state_unit_class: string | null;
metadata_unit: string; metadata_unit: string;
metadata_unit_class: string | null;
supported_unit: string; supported_unit: string;
}; };
} }
@@ -233,14 +231,12 @@ export const validateStatistics = (hass: HomeAssistant) =>
export const updateStatisticsMetadata = ( export const updateStatisticsMetadata = (
hass: HomeAssistant, hass: HomeAssistant,
statistic_id: string, statistic_id: string,
unit_of_measurement: string | null, unit_of_measurement: string | null
unit_class: string | null
) => ) =>
hass.callWS<undefined>({ hass.callWS<undefined>({
type: "recorder/update_statistics_metadata", type: "recorder/update_statistics_metadata",
statistic_id, statistic_id,
unit_of_measurement, unit_of_measurement,
unit_class,
}); });
export const clearStatistics = (hass: HomeAssistant, statistic_ids: string[]) => export const clearStatistics = (hass: HomeAssistant, statistic_ids: string[]) =>

View File

@@ -18,7 +18,6 @@ import type {
EntityRegistryEntry, EntityRegistryEntry,
} from "./entity_registry"; } from "./entity_registry";
import type { EntitySources } from "./entity_sources"; import type { EntitySources } from "./entity_sources";
import type { EntityNameItem } from "../common/entity/compute_entity_name_display";
export type Selector = export type Selector =
| ActionSelector | ActionSelector
@@ -42,7 +41,6 @@ export type Selector =
| LegacyDeviceSelector | LegacyDeviceSelector
| DurationSelector | DurationSelector
| EntitySelector | EntitySelector
| EntityNameSelector
| LegacyEntitySelector | LegacyEntitySelector
| FileSelector | FileSelector
| IconSelector | IconSelector
@@ -501,13 +499,6 @@ export interface UiStateContentSelector {
} | null; } | null;
} }
export interface EntityNameSelector {
entity_name: {
entity_id?: string;
default_name?: EntityNameItem | EntityNameItem[] | string;
} | null;
}
export const expandLabelTarget = ( export const expandLabelTarget = (
hass: HomeAssistant, hass: HomeAssistant,
labelId: string, labelId: string,

View File

@@ -76,10 +76,7 @@ export const formatSelectorValue = (
if (!stateObj) { if (!stateObj) {
return entityId; return entityId;
} }
const name = hass.formatEntityName(stateObj, [ const name = hass.formatEntityName(stateObj, ["device", "entity"], " ");
{ type: "device" },
{ type: "entity" },
]);
return name || entityId; return name || entityId;
}) })
.join(", "); .join(", ");

View File

@@ -1,164 +0,0 @@
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 { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
export type TargetType = "entity" | "device" | "area" | "label" | "floor";
export type TargetTypeFloorless = Exclude<TargetType, "floor">;
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 interface ExtractFromTargetResultReferenced {
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

@@ -43,7 +43,7 @@ export type ModernForecastType = "hourly" | "daily" | "twice_daily";
export type ForecastType = ModernForecastType | "legacy"; export type ForecastType = ModernForecastType | "legacy";
export interface ForecastAttribute { interface ForecastAttribute {
temperature: number; temperature: number;
datetime: string; datetime: string;
templow?: number; templow?: number;

View File

@@ -77,84 +77,80 @@ class MoreInfoMediaPlayer extends LitElement {
return nothing; return nothing;
} }
if (!stateActive(this.stateObj)) {
return nothing;
}
const supportsMute = supportsFeature( const supportsMute = supportsFeature(
this.stateObj, this.stateObj,
MediaPlayerEntityFeature.VOLUME_MUTE MediaPlayerEntityFeature.VOLUME_MUTE
); );
const supportsSet = supportsFeature( const supportsSliding = supportsFeature(
this.stateObj, this.stateObj,
MediaPlayerEntityFeature.VOLUME_SET MediaPlayerEntityFeature.VOLUME_SET
); );
const supportsStep = supportsFeature( return html`${(supportsFeature(
this.stateObj, this.stateObj!,
MediaPlayerEntityFeature.VOLUME_STEP MediaPlayerEntityFeature.VOLUME_SET
); ) ||
supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) &&
if (!supportsMute && !supportsSet && !supportsStep) { stateActive(this.stateObj!)
return nothing; ? html`
} <div class="volume">
${supportsMute
return html` ? html`
<div class="volume"> <ha-icon-button
${supportsMute .path=${this.stateObj.attributes.is_volume_muted
? html` ? mdiVolumeOff
<ha-icon-button : mdiVolumeHigh}
.path=${this.stateObj.attributes.is_volume_muted .label=${this.hass.localize(
? mdiVolumeOff `ui.card.media_player.${
: mdiVolumeHigh} this.stateObj.attributes.is_volume_muted
.label=${this.hass.localize( ? "media_volume_unmute"
`ui.card.media_player.${ : "media_volume_mute"
this.stateObj.attributes.is_volume_muted }`
? "media_volume_unmute" )}
: "media_volume_mute" @click=${this._toggleMute}
}` ></ha-icon-button>
)} `
@click=${this._toggleMute} : ""}
></ha-icon-button> ${supportsFeature(
` this.stateObj,
: nothing} MediaPlayerEntityFeature.VOLUME_STEP
${supportsStep ) && !supportsSliding
? html` <ha-icon-button ? html`
action="volume_down" <ha-icon-button
.path=${mdiVolumeMinus} action="volume_down"
.label=${this.hass.localize( .path=${mdiVolumeMinus}
"ui.card.media_player.media_volume_down" .label=${this.hass.localize(
)} "ui.card.media_player.media_volume_down"
@click=${this._handleClick} )}
></ha-icon-button>` @click=${this._handleClick}
: nothing} ></ha-icon-button>
${supportsSet <ha-icon-button
? html` action="volume_up"
${!supportsMute && !supportsStep .path=${mdiVolumePlus}
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>` .label=${this.hass.localize(
: nothing} "ui.card.media_player.media_volume_up"
<ha-slider )}
labeled @click=${this._handleClick}
id="input" ></ha-icon-button>
.value=${Number(this.stateObj.attributes.volume_level) * 100} `
@change=${this._selectedValueChanged} : nothing}
></ha-slider> ${supportsSliding
` ? html`
: nothing} ${!supportsMute
${supportsStep ? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
? html` : nothing}
<ha-icon-button <ha-slider
action="volume_up" labeled
.path=${mdiVolumePlus} id="input"
.label=${this.hass.localize( .value=${Number(this.stateObj.attributes.volume_level) *
"ui.card.media_player.media_volume_up" 100}
)} @change=${this._selectedValueChanged}
@click=${this._handleClick} ></ha-slider>
></ha-icon-button> `
` : nothing}
: nothing} </div>
</div> `
`; : nothing}`;
} }
protected _renderSourceControl() { protected _renderSourceControl() {

View File

@@ -16,7 +16,6 @@ import "../../../components/ha-tab-group";
import "../../../components/ha-tab-group-tab"; import "../../../components/ha-tab-group-tab";
import "../../../components/ha-tooltip"; import "../../../components/ha-tooltip";
import type { import type {
ForecastAttribute,
ForecastEvent, ForecastEvent,
ModernForecastType, ModernForecastType,
WeatherEntity, WeatherEntity,
@@ -132,24 +131,6 @@ class MoreInfoWeather extends LitElement {
getSupportedForecastTypes(stateObj) getSupportedForecastTypes(stateObj)
); );
private _groupForecastByDay = memoizeOne((forecast: ForecastAttribute[]) => {
if (!forecast) return [];
const grouped = new Map<string, NonNullable<typeof forecast>>();
forecast.forEach((item) => {
const date = new Date(item.datetime);
const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
}
grouped.get(dateKey)!.push(item);
});
return Array.from(grouped.values());
});
protected render() { protected render() {
if (!this.hass || !this.stateObj) { if (!this.hass || !this.stateObj) {
return nothing; return nothing;
@@ -333,90 +314,78 @@ class MoreInfoWeather extends LitElement {
: nothing} : nothing}
<div class="forecast"> <div class="forecast">
${forecast?.length ${forecast?.length
? this._groupForecastByDay(forecast).map((dayForecast) => { ? forecast.map((item) =>
const showDayHeader = hourly || dayNight; this._showValue(item.templow) || this._showValue(item.temperature)
return html` ? html`
<div class="forecast-day"> <div>
${showDayHeader <div>
? html`<div class="forecast-day-header"> ${dayNight
${formatDateWeekdayShort( ? html`
new Date(dayForecast[0].datetime), ${formatDateWeekdayShort(
this.hass!.locale, new Date(item.datetime),
this.hass!.config this.hass!.locale,
)} this.hass!.config
</div>` )}
: nothing} <div class="daynight">
<div class="forecast-day-content"> ${item.is_daytime !== false
${dayForecast.map((item) => ? this.hass!.localize("ui.card.weather.day")
this._showValue(item.templow) || : this.hass!.localize(
this._showValue(item.temperature) "ui.card.weather.night"
)}<br />
</div>
`
: hourly
? html`
${formatTime(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
`
: html`
${formatDateWeekdayShort(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
`}
</div>
${this._showValue(item.condition)
? html` ? html`
<div class="forecast-item"> <div class="forecast-image-icon">
<div ${getWeatherStateIcon(
class="forecast-item-label ${showDayHeader item.condition!,
? "" this,
: "no-header"}" !(
> item.is_daytime ||
${hourly item.is_daytime === undefined
? formatTime( )
new Date(item.datetime), )}
this.hass!.locale,
this.hass!.config
)
: dayNight
? html`<div class="daynight">
${item.is_daytime !== false
? this.hass!.localize(
"ui.card.weather.day"
)
: this.hass!.localize(
"ui.card.weather.night"
)}
</div>`
: formatDateWeekdayShort(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
</div>
${this._showValue(item.condition)
? html`
<div class="forecast-image-icon">
${getWeatherStateIcon(
item.condition!,
this,
!(
item.is_daytime ||
item.is_daytime === undefined
)
)}
</div>
`
: nothing}
<div class="temp">
${this._showValue(item.temperature)
? html`${formatNumber(
item.temperature,
this.hass!.locale
)}°`
: "—"}
</div>
<div class="templow">
${this._showValue(item.templow)
? html`${formatNumber(
item.templow!,
this.hass!.locale
)}°`
: nothing}
</div>
</div> </div>
` `
: nothing : nothing}
)} <div class="temp">
</div> ${this._showValue(item.temperature)
</div> ? html`${formatNumber(
`; item.temperature,
}) this.hass!.locale
)}°`
: "—"}
</div>
<div class="templow">
${this._showValue(item.templow)
? html`${formatNumber(
item.templow!,
this.hass!.locale
)}°`
: hourly
? nothing
: "—"}
</div>
</div>
`
: nothing
)
: html`<ha-spinner size="medium"></ha-spinner>`} : html`<ha-spinner size="medium"></ha-spinner>`}
</div> </div>
@@ -587,46 +556,14 @@ class MoreInfoWeather extends LitElement {
user-select: none; user-select: none;
} }
.forecast-day { .forecast > div {
display: flex;
flex-direction: column;
}
.forecast-day-header {
position: sticky;
top: 0;
left: 0;
color: var(--primary-text-color);
z-index: 1;
padding: 0 var(--ha-space-3) var(--ha-space-1) var(--ha-space-3);
width: fit-content;
width: 40px;
text-align: center; text-align: center;
font-weight: var(--ha-font-weight-semi-bold); padding: 0 10px;
}
.forecast-day-content {
display: flex;
flex-direction: row;
}
.forecast-item {
text-align: center;
padding: 0 var(--ha-space-3);
}
.forecast-item-label {
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.forecast-item-label.no-header {
color: var(--primary-text-color);
} }
.forecast .icon, .forecast .icon,
.forecast .temp { .forecast .temp {
margin: var(--ha-space-1) 0; margin: 4px 0;
} }
.forecast .temp { .forecast .temp {

View File

@@ -23,14 +23,8 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { import { computeEntityEntryName } from "../../common/entity/compute_entity_name";
computeEntityEntryName, import { getEntityEntryContext } from "../../common/entity/context/get_entity_context";
computeEntityName,
} from "../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event"; import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import "../../components/ha-button-menu"; import "../../components/ha-button-menu";
@@ -327,34 +321,28 @@ export class MoreInfoDialog extends LitElement {
(isDefaultView && this._parentEntityIds.length === 0) || (isDefaultView && this._parentEntityIds.length === 0) ||
isSpecificInitialView; isSpecificInitialView;
const context = stateObj let entityName: string | undefined;
? getEntityContext( let deviceName: string | undefined;
stateObj, let areaName: string | undefined;
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? getEntityEntryContext(
this._entry,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: undefined;
const entityName = stateObj if (stateObj) {
? computeEntityName(stateObj, this.hass.entities, this.hass.devices) entityName = this.hass.formatEntityName(stateObj, "entity");
: this._entry deviceName = this.hass.formatEntityName(stateObj, "device");
? computeEntityEntryName(this._entry, this.hass.devices) areaName = this.hass.formatEntityName(stateObj, "area");
: entityId; } else if (this._entry) {
const { device, area } = getEntityEntryContext(
const deviceName = context?.device this._entry,
? computeDeviceName(context.device) this.hass.entities,
: undefined; this.hass.devices,
const areaName = context?.area ? computeAreaName(context.area) : undefined; this.hass.areas,
this.hass.floors
);
entityName = computeEntityEntryName(this._entry, this.hass.devices);
deviceName = device ? computeDeviceName(device) : undefined;
areaName = area ? computeAreaName(area) : undefined;
} else {
entityName = entityId;
}
const breadcrumb = [areaName, deviceName, entityName].filter( const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v) (v): v is string => Boolean(v)

View File

@@ -23,7 +23,6 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { entityUseDeviceName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
@@ -31,9 +30,9 @@ import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import "../../components/ha-button";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-label"; import "../../components/ha-label";
import "../../components/ha-button";
import "../../components/ha-list"; import "../../components/ha-list";
import "../../components/ha-md-list-item"; import "../../components/ha-md-list-item";
import "../../components/ha-spinner"; import "../../components/ha-spinner";
@@ -632,29 +631,14 @@ export class QuickBar extends LitElement {
const stateObj = this.hass.states[entityId]; const stateObj = this.hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search 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 useDeviceName = entityUseDeviceName( const primary = entityName || deviceName || entityId;
stateObj, const secondary = [areaName, entityName ? deviceName : undefined]
this.hass.entities, .filter(Boolean)
this.hass.devices .join(isRTL ? " ◂ " : " ▸ ");
);
const name = this.hass.formatEntityName(
stateObj,
useDeviceName ? { type: "device" } : { type: "entity" }
);
const primary = name || entityId;
const secondary = this.hass.formatEntityName(
stateObj,
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}
);
const translatedDomain = domainToName( const translatedDomain = domainToName(
this.hass.localize, this.hass.localize,

View File

@@ -1,6 +1,7 @@
import "@material/mwc-linear-progress/mwc-linear-progress"; import "@material/mwc-linear-progress/mwc-linear-progress";
import { import {
mdiAutoFix, mdiAutoFix,
mdiClose,
mdiLifebuoy, mdiLifebuoy,
mdiPower, mdiPower,
mdiPowerCycle, mdiPowerCycle,
@@ -8,14 +9,16 @@ import {
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-alert"; import "../../components/ha-alert";
import "../../components/ha-expansion-panel"; import "../../components/ha-expansion-panel";
import "../../components/ha-fade-in"; import "../../components/ha-fade-in";
import "../../components/ha-icon-button";
import "../../components/ha-icon-next"; import "../../components/ha-icon-next";
import "../../components/ha-wa-dialog"; import "../../components/ha-md-dialog";
import type { HaMdDialog } from "../../components/ha-md-dialog";
import "../../components/ha-md-list"; import "../../components/ha-md-list";
import "../../components/ha-md-list-item"; import "../../components/ha-md-list-item";
import "../../components/ha-spinner"; import "../../components/ha-spinner";
@@ -55,14 +58,12 @@ class DialogRestart extends LitElement {
@state() @state()
private _hostInfo?: HassioHostInfo; private _hostInfo?: HassioHostInfo;
@state() @query("ha-md-dialog") private _dialog?: HaMdDialog;
private _dialogOpen = false;
public async showDialog(): Promise<void> { public async showDialog(): Promise<void> {
const isHassioLoaded = isComponentLoaded(this.hass, "hassio"); const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
this._open = true; this._open = true;
this._dialogOpen = true;
if (isHassioLoaded && !this._hostInfo) { if (isHassioLoaded && !this._hostInfo) {
this._loadHostInfo(); this._loadHostInfo();
@@ -91,13 +92,16 @@ class DialogRestart extends LitElement {
} }
private _dialogClosed(): void { private _dialogClosed(): void {
this._dialogOpen = false;
this._open = false; this._open = false;
this._loadingHostInfo = false; this._loadingHostInfo = false;
this._loadingBackupInfo = false; this._loadingBackupInfo = false;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
public closeDialog(): void {
this._dialog?.close();
}
protected render() { protected render() {
if (!this._open) { if (!this._open) {
return nothing; return nothing;
@@ -109,13 +113,17 @@ class DialogRestart extends LitElement {
const dialogTitle = this.hass.localize("ui.dialogs.restart.heading"); const dialogTitle = this.hass.localize("ui.dialogs.restart.heading");
return html` return html`
<ha-wa-dialog <ha-md-dialog open @closed=${this._dialogClosed}>
.hass=${this.hass} <ha-dialog-header slot="headline">
.open=${this._dialogOpen} <ha-icon-button
header-title=${dialogTitle} slot="navigationIcon"
@closed=${this._dialogClosed} .label=${this.hass.localize("ui.common.close") ?? "Close"}
> .path=${mdiClose}
<div class="content"> @click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header>
<div slot="content" class="content">
<div class="action-loader"> <div class="action-loader">
${this._loadingBackupInfo ${this._loadingBackupInfo
? html`<ha-fade-in .delay=${250}> ? html`<ha-fade-in .delay=${250}>
@@ -257,12 +265,12 @@ class DialogRestart extends LitElement {
</ha-expansion-panel> </ha-expansion-panel>
`} `}
</div> </div>
</ha-wa-dialog> </ha-md-dialog>
`; `;
} }
private async _reload() { private async _reload() {
this._dialogOpen = false; this.closeDialog();
showToast(this, { showToast(this, {
message: this.hass.localize("ui.dialogs.restart.reload.reloading"), message: this.hass.localize("ui.dialogs.restart.reload.reloading"),
@@ -366,7 +374,7 @@ class DialogRestart extends LitElement {
return; return;
} }
this._dialogOpen = false; this.closeDialog();
let actionFunc; let actionFunc;
@@ -405,9 +413,15 @@ class DialogRestart extends LitElement {
haStyle, haStyle,
haStyleDialog, haStyleDialog,
css` css`
ha-wa-dialog { ha-md-dialog {
--dialog-content-padding: 0; --dialog-content-padding: 0;
} }
@media all and (min-width: 550px) {
ha-md-dialog {
min-width: 500px;
max-width: 500px;
}
}
ha-expansion-panel { ha-expansion-panel {
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);

View File

@@ -110,7 +110,7 @@ class MoveDatadiskDialog extends LitElement {
> >
${this._moving ${this._moving
? html` ? html`
<ha-spinner aria-label="Moving" size="large"></ha-spinner> <ha-spinner aria-label="Moving" size="large"> </ha-spinner>
<p class="progress-text"> <p class="progress-text">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.storage.datadisk.moving_desc" "ui.panel.config.storage.datadisk.moving_desc"
@@ -206,7 +206,8 @@ class MoveDatadiskDialog extends LitElement {
} }
ha-spinner { ha-spinner {
display: block; display: block;
margin: 32px auto; margin: 32px;
text-align: center;
} }
.progress-text { .progress-text {

View File

@@ -139,8 +139,7 @@ export class DialogStatisticsFixUnitsChanged extends LitElement {
await updateStatisticsMetadata( await updateStatisticsMetadata(
this.hass, this.hass,
this._params!.issue.data.statistic_id, this._params!.issue.data.statistic_id,
this._params!.issue.data.state_unit, this._params!.issue.data.state_unit
this._params!.issue.data.state_unit_class
); );
} }
this._params?.fixedCallback!(); this._params?.fixedCallback!();

View File

@@ -1,10 +1,10 @@
import type { ActionDetail } from "@material/mwc-list";
import { import {
mdiDotsVertical, mdiDotsVertical,
mdiDownload, mdiDownload,
mdiFilterRemove, mdiFilterRemove,
mdiImagePlus, mdiImagePlus,
} from "@mdi/js"; } from "@mdi/js";
import type { ActionDetail } from "@material/mwc-list";
import { differenceInHours } from "date-fns"; import { differenceInHours } from "date-fns";
import type { import type {
HassServiceTarget, HassServiceTarget,
@@ -27,21 +27,21 @@ import {
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base"; import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts"; import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts"; import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
import "../../components/ha-button-menu"; import "../../components/ha-spinner";
import "../../components/ha-date-range-picker"; import "../../components/ha-date-range-picker";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev"; import "../../components/ha-button-menu";
import "../../components/ha-list-item"; import "../../components/ha-list-item";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button"; import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-target-picker"; import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed"; import "../../components/ha-top-app-bar-fixed";
import type { HistoryResult } from "../../data/history"; import type { HistoryResult } from "../../data/history";
import { import {
computeHistory, computeHistory,
convertStatisticsToHistory,
mergeHistoryResults,
subscribeHistory, subscribeHistory,
mergeHistoryResults,
convertStatisticsToHistory,
} from "../../data/history"; } from "../../data/history";
import { fetchStatistics } from "../../data/recorder"; import { fetchStatistics } from "../../data/recorder";
import { resolveEntityIDs } from "../../data/selector"; import { resolveEntityIDs } from "../../data/selector";
@@ -182,7 +182,6 @@ class HaPanelHistory extends LitElement {
.disabled=${this._isLoading} .disabled=${this._isLoading}
add-on-top add-on-top
@value-changed=${this._targetsChanged} @value-changed=${this._targetsChanged}
compact
></ha-target-picker> ></ha-target-picker>
</div> </div>
${this._isLoading ${this._isLoading
@@ -650,10 +649,6 @@ class HaPanelHistory extends LitElement {
direction: var(--direction); direction: var(--direction);
} }
ha-target-picker {
flex: 1;
}
@media all and (max-width: 1025px) { @media all and (max-width: 1025px) {
.filters { .filters {
flex-direction: column; flex-direction: column;

View File

@@ -1,11 +1,9 @@
import { mdiRefresh } from "@mdi/js"; import { mdiRefresh } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one"; 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 { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url"; import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { import {
@@ -18,15 +16,17 @@ import "../../components/ha-date-range-picker";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev"; import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button"; import "../../components/ha-menu-button";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed"; import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; import "../../components/ha-target-picker";
import { filterLogbookCompatibleEntities } from "../../data/logbook"; import { filterLogbookCompatibleEntities } from "../../data/logbook";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "./ha-logbook"; 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";
@customElement("ha-panel-logbook") @customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement { export class HaPanelLogbook extends LitElement {
@@ -108,7 +108,6 @@ export class HaPanelLogbook extends LitElement {
.value=${this._targetPickerValue} .value=${this._targetPickerValue}
add-on-top add-on-top
@value-changed=${this._targetsChanged} @value-changed=${this._targetsChanged}
compact
></ha-target-picker> ></ha-target-picker>
</div> </div>
@@ -364,10 +363,6 @@ export class HaPanelLogbook extends LitElement {
max-width: 400px; max-width: 400px;
} }
ha-target-picker {
flex: 1;
}
:host([narrow]) ha-entity-picker { :host([narrow]) ha-entity-picker {
max-width: none; max-width: none;
width: 100%; width: 100%;

View File

@@ -1,4 +1,4 @@
import { mdiTextureBox } from "@mdi/js"; import { mdiPlay, mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { import {
css, css,
@@ -13,6 +13,10 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import {
computeActiveAreaMediaStates,
type MediaPlayerEntity,
} from "../../../data/media-player";
import { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { BINARY_STATE_ON } from "../../../common/const"; import { BINARY_STATE_ON } from "../../../common/const";
import { computeAreaName } from "../../../common/entity/compute_area_name"; import { computeAreaName } from "../../../common/entity/compute_area_name";
@@ -285,15 +289,19 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
); );
} }
private _renderAlertSensorBadge(): TemplateResult<1> | typeof nothing { private _computeActiveAreaMediaStates(): MediaPlayerEntity[] {
const states = this._computeActiveAlertStates(); return computeActiveAreaMediaStates(this.hass, this._config?.area || "");
}
if (states.length === 0) { private _renderAlertSensorBadge(
alertStates: HassEntity[]
): TemplateResult<1> | typeof nothing {
if (alertStates.length === 0) {
return nothing; return nothing;
} }
// Only render the first one when using a badge // Only render the first one when using a badge
const stateObj = states[0] as HassEntity | undefined; const stateObj = alertStates[0] as HassEntity | undefined;
return html` return html`
<ha-tile-badge class="alert-badge"> <ha-tile-badge class="alert-badge">
@@ -302,6 +310,30 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
`; `;
} }
private _renderMediaBadge(): TemplateResult<1> | typeof nothing {
const states = this._computeActiveAreaMediaStates();
if (states.length === 0) {
return nothing;
}
return html`
<ha-tile-badge
class="media-badge"
.label=${this.hass.localize("ui.card.area.media_playing")}
>
<ha-svg-icon .path=${mdiPlay}></ha-svg-icon>
</ha-tile-badge>
`;
}
private _renderCompactBadge(): TemplateResult<1> | typeof nothing {
const alertStates = this._computeActiveAlertStates();
return alertStates.length > 0
? this._renderAlertSensorBadge(alertStates)
: this._renderMediaBadge();
}
private _renderAlertSensors(): TemplateResult<1> | typeof nothing { private _renderAlertSensors(): TemplateResult<1> | typeof nothing {
const states = this._computeActiveAlertStates(); const states = this._computeActiveAlertStates();
@@ -563,7 +595,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
<div class="content ${classMap(contentClasses)}"> <div class="content ${classMap(contentClasses)}">
<ha-tile-icon> <ha-tile-icon>
${displayType === "compact" ${displayType === "compact"
? this._renderAlertSensorBadge() ? this._renderCompactBadge()
: nothing} : nothing}
${icon ${icon
? html`<ha-icon slot="icon" .icon=${icon}></ha-icon>` ? html`<ha-icon slot="icon" .icon=${icon}></ha-icon>`
@@ -741,6 +773,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
.alert-badge { .alert-badge {
--tile-badge-background-color: var(--orange-color); --tile-badge-background-color: var(--orange-color);
} }
.media-badge {
--tile-badge-background-color: var(--light-blue-color);
}
.alerts { .alerts {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -49,8 +49,6 @@ import type {
LovelaceGridOptions, LovelaceGridOptions,
} from "../types"; } from "../types";
import type { ButtonCardConfig } from "./types"; import type { ButtonCardConfig } from "./types";
import { computeCssColor } from "../../../common/color/compute-color";
import { stateActive } from "../../../common/entity/state_active";
export const getEntityDefaultButtonAction = (entityId?: string) => export const getEntityDefaultButtonAction = (entityId?: string) =>
entityId && DOMAINS_TOGGLE.has(computeDomain(entityId)) entityId && DOMAINS_TOGGLE.has(computeDomain(entityId))
@@ -124,6 +122,11 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
}) })
_entity?: EntityRegistryDisplayEntry; _entity?: EntityRegistryDisplayEntry;
private _getStateColor(stateObj: HassEntity, config: ButtonCardConfig) {
const domain = stateObj ? computeStateDomain(stateObj) : undefined;
return config && (config.state_color ?? domain === "light");
}
public getCardSize(): number { public getCardSize(): number {
return ( return (
(this._config?.show_icon ? 4 : 0) + (this._config?.show_name ? 1 : 0) (this._config?.show_icon ? 4 : 0) + (this._config?.show_name ? 1 : 0)
@@ -163,8 +166,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
double_tap_action: { action: "none" }, double_tap_action: { action: "none" },
show_icon: true, show_icon: true,
show_name: true, show_name: true,
color: state_color: true,
config.color ?? (config.state_color === false ? "none" : undefined),
...config, ...config,
}; };
} }
@@ -187,6 +189,8 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
? this._config.name || (stateObj ? computeStateName(stateObj) : "") ? this._config.name || (stateObj ? computeStateName(stateObj) : "")
: ""; : "";
const colored = stateObj && this._getStateColor(stateObj, this._config);
return html` return html`
<ha-card <ha-card
@action=${this._handleAction} @action=${this._handleAction}
@@ -201,10 +205,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
hasAction(this._config.tap_action) ? "0" : undefined hasAction(this._config.tap_action) ? "0" : undefined
)} )}
style=${styleMap({ style=${styleMap({
"--state-color": "--state-color": colored ? this._computeColor(stateObj) : undefined,
this._config.color !== "none"
? this._computeColor(stateObj, this._config)
: undefined,
})} })}
> >
<ha-ripple></ha-ripple> <ha-ripple></ha-ripple>
@@ -220,7 +221,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
.hass=${this.hass} .hass=${this.hass}
.stateObj=${stateObj} .stateObj=${stateObj}
style=${styleMap({ style=${styleMap({
filter: stateObj ? stateColorBrightness(stateObj) : undefined, filter: colored ? stateColorBrightness(stateObj) : undefined,
height: this._config.icon_height height: this._config.icon_height
? this._config.icon_height ? this._config.icon_height
: undefined, : undefined,
@@ -333,20 +334,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
]; ];
} }
private _computeColor( private _computeColor(stateObj: HassEntity): string | undefined {
stateObj: HassEntity | undefined,
config: ButtonCardConfig
): string | undefined {
if (config.color) {
return !stateObj || stateActive(stateObj)
? computeCssColor(config.color)
: undefined;
}
if (!stateObj) {
return undefined;
}
if (stateObj.attributes.rgb_color) { if (stateObj.attributes.rgb_color) {
return `rgb(${stateObj.attributes.rgb_color.join(",")})`; return `rgb(${stateObj.attributes.rgb_color.join(",")})`;
} }

View File

@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
@@ -9,7 +9,7 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
import { DOMAINS_TOGGLE } from "../../../common/const"; import { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { stateActive } from "../../../common/entity/state_active"; import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color"; import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-card"; import "../../../components/ha-card";
@@ -47,11 +47,6 @@ export const getEntityDefaultTileIconAction = (entityId: string) => {
return supportsIconAction ? "toggle" : "none"; return supportsIconAction ? "toggle" : "none";
}; };
export const DEFAULT_NAME = [
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
@customElement("hui-tile-card") @customElement("hui-tile-card")
export class HuiTileCard extends LitElement implements LovelaceCard { export class HuiTileCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> { public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -260,13 +255,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const contentClasses = { vertical: Boolean(this._config.vertical) }; const contentClasses = { vertical: Boolean(this._config.vertical) };
const nameConfig = this._config.name; const name = this._config.name || computeStateName(stateObj);
const nameDisplay =
typeof nameConfig === "string"
? nameConfig
: this.hass.formatEntityName(stateObj, nameConfig || DEFAULT_NAME);
const active = stateActive(stateObj); const active = stateActive(stateObj);
const color = this._computeStateColor(stateObj, this._config.color); const color = this._computeStateColor(stateObj, this._config.color);
const domain = computeDomain(stateObj.entity_id); const domain = computeDomain(stateObj.entity_id);
@@ -278,7 +267,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.stateObj=${stateObj} .stateObj=${stateObj}
.hass=${this.hass} .hass=${this.hass}
.content=${this._config.state_content} .content=${this._config.state_content}
.name=${nameDisplay} .name=${this._config.name}
> >
</state-display> </state-display>
`; `;
@@ -337,7 +326,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
${renderTileBadge(stateObj, this.hass)} ${renderTileBadge(stateObj, this.hass)}
</ha-tile-icon> </ha-tile-icon>
<ha-tile-info id="info"> <ha-tile-info id="info">
<span slot="primary" class="primary">${nameDisplay}</span> <span slot="primary" class="primary">${name}</span>
${stateDisplay ${stateDisplay
? html`<span slot="secondary">${stateDisplay}</span>` ? html`<span slot="secondary">${stateDisplay}</span>`
: nothing} : nothing}

View File

@@ -331,16 +331,14 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
: nothing} : nothing}
${!this._reordering && uncheckedItems.length ${!this._reordering && uncheckedItems.length
? html` ? html`
${!this._config.hide_section_headers <div class="header" role="separator">
? html`<div class="header"> <h2>
<h2> ${this.hass!.localize(
${this.hass!.localize( "ui.panel.lovelace.cards.todo-list.unchecked_items"
"ui.panel.lovelace.cards.todo-list.unchecked_items" )}
)} </h2>
</h2> ${this._renderMenu(this._config, unavailable)}
${this._renderMenu(this._config, unavailable)} </div>
</div>`
: nothing}
${this._renderItems(uncheckedItems, unavailable)} ${this._renderItems(uncheckedItems, unavailable)}
` `
: nothing} : nothing}
@@ -368,41 +366,39 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
? html` ? html`
<div> <div>
<div class="divider" role="separator"></div> <div class="divider" role="separator"></div>
${!this._config.hide_section_headers <div class="header">
? html`<div class="header"> <h2>
<h2> ${this.hass!.localize(
${this.hass!.localize( "ui.panel.lovelace.cards.todo-list.checked_items"
"ui.panel.lovelace.cards.todo-list.checked_items" )}
)} </h2>
</h2> ${this._todoListSupportsFeature(
${this._todoListSupportsFeature( TodoListEntityFeature.DELETE_TODO_ITEM
TodoListEntityFeature.DELETE_TODO_ITEM )
) ? html`<ha-button-menu
? html`<ha-button-menu @closed=${stopPropagation}
@closed=${stopPropagation} fixed
fixed @action=${this._handleCompletedMenuAction}
@action=${this._handleCompletedMenuAction} >
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon" class="warning">
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.clear_items"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDeleteSweep}
.disabled=${unavailable}
> >
<ha-icon-button </ha-svg-icon>
slot="trigger" </ha-list-item>
.path=${mdiDotsVertical} </ha-button-menu>`
></ha-icon-button> : nothing}
<ha-list-item graphic="icon" class="warning"> </div>
${this.hass!.localize(
"ui.panel.lovelace.cards.todo-list.clear_items"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDeleteSweep}
.disabled=${unavailable}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>`
: nothing}
</div>`
: nothing}
</div> </div>
${this._renderItems(checkedItems, unavailable)} ${this._renderItems(checkedItems, unavailable)}
` `

View File

@@ -1,11 +1,8 @@
import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display";
import type { HaDurationData } from "../../../components/ha-duration-input"; import type { HaDurationData } from "../../../components/ha-duration-input";
import type { EnergySourceByType } from "../../../data/energy";
import type { ActionConfig } from "../../../data/lovelace/config/action"; import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { Statistic, StatisticType } from "../../../data/recorder"; import type { Statistic, StatisticType } from "../../../data/recorder";
import type { TimeFormat } from "../../../data/translation";
import type { ForecastType } from "../../../data/weather"; import type { ForecastType } from "../../../data/weather";
import type { import type {
FullCalendarView, FullCalendarView,
@@ -28,7 +25,9 @@ import type {
} from "../entity-rows/types"; } from "../entity-rows/types";
import type { LovelaceHeaderFooterConfig } from "../header-footer/types"; import type { LovelaceHeaderFooterConfig } from "../header-footer/types";
import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
import type { TimeFormat } from "../../../data/translation";
import type { HomeSummary } from "../strategies/home/helpers/home-summaries"; import type { HomeSummary } from "../strategies/home/helpers/home-summaries";
import type { EnergySourceByType } from "../../../data/energy";
export type AlarmPanelCardConfigState = export type AlarmPanelCardConfigState =
| "arm_away" | "arm_away"
@@ -136,10 +135,8 @@ export interface ButtonCardConfig extends LovelaceCardConfig {
tap_action?: ActionConfig; tap_action?: ActionConfig;
hold_action?: ActionConfig; hold_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
/** @deprecated use `color` instead */
state_color?: boolean; state_color?: boolean;
show_state?: boolean; show_state?: boolean;
color?: string;
} }
export interface EnergyCardBaseConfig extends LovelaceCardConfig { export interface EnergyCardBaseConfig extends LovelaceCardConfig {
@@ -534,7 +531,6 @@ export interface TodoListCardConfig extends LovelaceCardConfig {
entity?: string; entity?: string;
hide_completed?: boolean; hide_completed?: boolean;
hide_create?: boolean; hide_create?: boolean;
hide_section_headers?: boolean;
sort?: string; sort?: string;
} }
@@ -572,7 +568,7 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
export interface TileCardConfig extends LovelaceCardConfig { export interface TileCardConfig extends LovelaceCardConfig {
entity: string; entity: string;
name?: string | EntityNameItem | EntityNameItem[]; name?: string;
hide_state?: boolean; hide_state?: boolean;
state_content?: string | string[]; state_content?: string | string[];
icon?: string; icon?: string;

View File

@@ -3,19 +3,20 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { entityUseDeviceName } from "../../../common/entity/compute_entity_name";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/entity/ha-entity-picker"; import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker"; import type {
HaEntityPicker,
HaEntityPickerEntityFilterFunc,
} from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-sortable"; import "../../../components/ha-sortable";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { EntityConfig } from "../entity-rows/types"; import type { EntityConfig } from "../entity-rows/types";
import { computeRTL } from "../../../common/util/compute_rtl";
@customElement("hui-entity-editor") @customElement("hui-entity-editor")
export class HuiEntityEditor extends LitElement { export class HuiEntityEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public entities?: EntityConfig[]; @property({ attribute: false }) public entities?: EntityConfig[];
@@ -37,32 +38,20 @@ export class HuiEntityEditor extends LitElement {
} }
private _renderItem(item: EntityConfig, index: number) { private _renderItem(item: EntityConfig, index: number) {
const stateObj = this.hass.states[item.entity]; const stateObj = this.hass!.states[item.entity];
const useDeviceName = entityUseDeviceName( const entityName =
stateObj, stateObj && this.hass!.formatEntityName(stateObj, "entity");
this.hass.entities, const deviceName =
this.hass.devices stateObj && this.hass!.formatEntityName(stateObj, "device");
); const areaName = stateObj && this.hass!.formatEntityName(stateObj, "area");
const name = this.hass.formatEntityName( const isRTL = computeRTL(this.hass!);
stateObj,
useDeviceName ? { type: "device" } : { type: "entity" }
);
const isRTL = computeRTL(this.hass); const primary = item.name || entityName || deviceName || item.entity;
const secondary = [areaName, entityName ? deviceName : undefined]
const primary = item.name || name || item.entity; .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const secondary = this.hass.formatEntityName(
stateObj,
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}
);
return html` return html`
<ha-md-list-item class="item"> <ha-md-list-item class="item">
@@ -78,14 +67,14 @@ export class HuiEntityEditor extends LitElement {
slot="end" slot="end"
.item=${item} .item=${item}
.index=${index} .index=${index}
.label=${this.hass.localize("ui.common.edit")} .label=${this.hass!.localize("ui.common.edit")}
.path=${mdiPencil} .path=${mdiPencil}
@click=${this._editItem} @click=${this._editItem}
></ha-icon-button> ></ha-icon-button>
<ha-icon-button <ha-icon-button
slot="end" slot="end"
.index=${index} .index=${index}
.label=${this.hass.localize("ui.common.delete")} .label=${this.hass!.localize("ui.common.delete")}
.path=${mdiClose} .path=${mdiClose}
@click=${this._deleteItem} @click=${this._deleteItem}
></ha-icon-button> ></ha-icon-button>
@@ -120,9 +109,9 @@ export class HuiEntityEditor extends LitElement {
return html` return html`
<h3> <h3>
${this.label || ${this.label ||
this.hass.localize("ui.panel.lovelace.editor.card.generic.entities") + this.hass!.localize("ui.panel.lovelace.editor.card.generic.entities") +
" (" + " (" +
this.hass.localize("ui.panel.lovelace.editor.card.config.required") + this.hass!.localize("ui.panel.lovelace.editor.card.config.required") +
")"} ")"}
</h3> </h3>
${this.canEdit ${this.canEdit

View File

@@ -6,7 +6,6 @@ import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../../../../common/translations/localize"; import type { LocalizeFunc } from "../../../../common/translations/localize";
import { computeRTL } from "../../../../common/util/compute_rtl"; import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/data-table/ha-data-table"; import "../../../../components/data-table/ha-data-table";
@@ -63,14 +62,9 @@ export class HuiEntityPickerTable extends LitElement {
(entity) => { (entity) => {
const stateObj = this.hass.states[entity]; const stateObj = this.hass.states[entity];
const [entityName, deviceName, areaName] = computeEntityNameList( const entityName = this.hass.formatEntityName(stateObj, "entity");
stateObj, const deviceName = this.hass.formatEntityName(stateObj, "device");
[{ type: "entity" }, { type: "device" }, { type: "area" }], const areaName = this.hass.formatEntityName(stateObj, "area");
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const name = [deviceName, entityName].filter(Boolean).join(" "); const name = [deviceName, entityName].filter(Boolean).join(" ");
const domain = computeDomain(entity); const domain = computeDomain(entity);

View File

@@ -32,8 +32,6 @@ const cardConfigStruct = assign(
double_tap_action: optional(actionConfigStruct), double_tap_action: optional(actionConfigStruct),
theme: optional(string()), theme: optional(string()),
show_state: optional(boolean()), show_state: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
}) })
); );
@@ -48,19 +46,6 @@ export class HuiButtonCardEditor
public setConfig(config: ButtonCardConfig): void { public setConfig(config: ButtonCardConfig): void {
assert(config, cardConfigStruct); assert(config, cardConfigStruct);
// Migrate state_color to color
if (config.state_color !== undefined) {
config = {
...config,
color: config.state_color ? undefined : "none",
};
delete config.state_color;
fireEvent(this, "config-changed", { config: config });
return;
}
this._config = config; this._config = config;
} }
@@ -68,11 +53,11 @@ export class HuiButtonCardEditor
(entityId: string | undefined) => (entityId: string | undefined) =>
[ [
{ name: "entity", selector: { entity: {} } }, { name: "entity", selector: { entity: {} } },
{ name: "name", selector: { text: {} } },
{ {
name: "", name: "",
type: "grid", type: "grid",
schema: [ schema: [
{ name: "name", selector: { text: {} } },
{ {
name: "icon", name: "icon",
selector: { selector: {
@@ -82,18 +67,6 @@ export class HuiButtonCardEditor
icon_entity: "entity", icon_entity: "entity",
}, },
}, },
{ name: "icon_height", selector: { text: { suffix: "px" } } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
{ name: "theme", selector: { theme: {} } },
], ],
}, },
{ {
@@ -106,6 +79,14 @@ export class HuiButtonCardEditor
{ name: "show_icon", selector: { boolean: {} } }, { name: "show_icon", selector: { boolean: {} } },
], ],
}, },
{
name: "",
type: "grid",
schema: [
{ name: "icon_height", selector: { text: { suffix: "px" } } },
{ name: "theme", selector: { theme: {} } },
],
},
{ {
name: "interactions", name: "interactions",
type: "expandable", type: "expandable",

View File

@@ -148,10 +148,10 @@ export class HuiHeadingBadgesEditor extends LitElement {
.hass=${this.hass} .hass=${this.hass}
id="input" id="input"
.placeholder=${this.hass.localize( .placeholder=${this.hass.localize(
"ui.components.entity.entity-picker.choose_entity" "ui.components.target-picker.add_entity_id"
)} )}
.searchLabel=${this.hass.localize( .searchLabel=${this.hass.localize(
"ui.components.entity.entity-picker.choose_entity" "ui.components.target-picker.add_entity_id"
)} )}
@value-changed=${this._entityPicked} @value-changed=${this._entityPicked}
@click=${preventDefault} @click=${preventDefault}

View File

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

View File

@@ -30,15 +30,11 @@ import type {
LovelaceCardFeatureConfig, LovelaceCardFeatureConfig,
LovelaceCardFeatureContext, LovelaceCardFeatureContext,
} from "../../card-features/types"; } from "../../card-features/types";
import { import { getEntityDefaultTileIconAction } from "../../cards/hui-tile-card";
DEFAULT_NAME,
getEntityDefaultTileIconAction,
} from "../../cards/hui-tile-card";
import type { TileCardConfig } from "../../cards/types"; import type { TileCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct"; import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import type { EditDetailElementEvent, EditSubElementEvent } from "../types"; import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
import { configElementStyle } from "./config-elements-style"; import { configElementStyle } from "./config-elements-style";
import { getSupportedFeaturesType } from "./hui-card-features-editor"; import { getSupportedFeaturesType } from "./hui-card-features-editor";
@@ -47,7 +43,7 @@ const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
entity: optional(string()), entity: optional(string()),
name: optional(entityNameStruct), name: optional(string()),
icon: optional(string()), icon: optional(string()),
color: optional(string()), color: optional(string()),
show_entity_picture: optional(boolean()), show_entity_picture: optional(boolean()),
@@ -101,19 +97,11 @@ export class HuiTileCardEditor
type: "expandable", type: "expandable",
iconPath: mdiTextShort, iconPath: mdiTextShort,
schema: [ schema: [
{
name: "name",
selector: {
entity_name: {
default_name: DEFAULT_NAME,
},
},
context: { entity: "entity" },
},
{ {
name: "", name: "",
type: "grid", type: "grid",
schema: [ schema: [
{ name: "name", selector: { text: {} } },
{ {
name: "icon", name: "icon",
selector: { selector: {

View File

@@ -32,7 +32,6 @@ const cardConfigStruct = assign(
entity: optional(string()), entity: optional(string()),
hide_completed: optional(boolean()), hide_completed: optional(boolean()),
hide_create: optional(boolean()), hide_create: optional(boolean()),
hide_section_headers: optional(boolean()),
display_order: optional(string()), display_order: optional(string()),
item_tap_action: optional(string()), item_tap_action: optional(string()),
}) })
@@ -60,7 +59,6 @@ export class HuiTodoListEditor
{ name: "theme", selector: { theme: {} } }, { name: "theme", selector: { theme: {} } },
{ name: "hide_completed", selector: { boolean: {} } }, { name: "hide_completed", selector: { boolean: {} } },
{ name: "hide_create", selector: { boolean: {} } }, { name: "hide_create", selector: { boolean: {} } },
{ name: "hide_section_headers", selector: { boolean: {} } },
{ {
name: "display_order", name: "display_order",
selector: { selector: {
@@ -133,7 +131,6 @@ export class HuiTodoListEditor
.data=${this._data(this._config)} .data=${this._data(this._config)}
.schema=${this._schema(this.hass.localize, this._todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM))} .schema=${this._schema(this.hass.localize, this._todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM))}
.computeLabel=${this._computeLabelCallback} .computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
</div> </div>
@@ -167,7 +164,6 @@ export class HuiTodoListEditor
)})`; )})`;
case "hide_completed": case "hide_completed":
case "hide_create": case "hide_create":
case "hide_section_headers":
case "display_order": case "display_order":
case "item_tap_action": case "item_tap_action":
return this.hass!.localize( return this.hass!.localize(
@@ -180,19 +176,6 @@ export class HuiTodoListEditor
} }
}; };
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "hide_section_headers":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.todo-list.${schema.name}_helper`
);
default:
return undefined;
}
};
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return configElementStyle; return configElementStyle;
} }

View File

@@ -4,8 +4,8 @@ import { property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache"; import { cache } from "lit/directives/cache";
import type { HASSDomEvent } from "../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { handleStructError } from "../../../common/structs/handle-errors";
import { debounce } from "../../../common/util/debounce"; import { debounce } from "../../../common/util/debounce";
import { handleStructError } from "../../../common/structs/handle-errors";
import { deepEqual } from "../../../common/util/deep-equal"; import { deepEqual } from "../../../common/util/deep-equal";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-spinner"; import "../../../components/ha-spinner";
@@ -57,6 +57,8 @@ export abstract class HuiElementEditor<
@property({ attribute: false }) public context?: C; @property({ attribute: false }) public context?: C;
@property({ attribute: false }) public schema?;
@state() private _config?: T; @state() private _config?: T;
@state() private _configElement?: LovelaceGenericElementEditor; @state() private _configElement?: LovelaceGenericElementEditor;
@@ -312,6 +314,9 @@ export abstract class HuiElementEditor<
if (this._configElement && changedProperties.has("context")) { if (this._configElement && changedProperties.has("context")) {
this._configElement.context = this.context; this._configElement.context = this.context;
} }
if (this._configElement && changedProperties.has("schema")) {
this._configElement.schema = this.schema;
}
} }
private _handleUIConfigChanged(ev: UIConfigChangedEvent<T>) { private _handleUIConfigChanged(ev: UIConfigChangedEvent<T>) {
@@ -399,6 +404,7 @@ export abstract class HuiElementEditor<
configElement.lovelace = this.lovelace; configElement.lovelace = this.lovelace;
} }
configElement.context = this.context; configElement.context = this.context;
configElement.schema = this.schema;
configElement.addEventListener("config-changed", (ev) => configElement.addEventListener("config-changed", (ev) =>
this._handleUIConfigChanged(ev as UIConfigChangedEvent<T>) this._handleUIConfigChanged(ev as UIConfigChangedEvent<T>)
); );

View File

@@ -1,19 +0,0 @@
import { customElement, property } from "lit/decorators";
import type { HaFormSchema } from "../../../components/ha-form/types";
import type { LovelaceConfigForm } from "../types";
import { HuiElementEditor } from "./hui-element-editor";
@customElement("hui-form-element-editor")
export class HuiFormElementEditor extends HuiElementEditor {
@property({ attribute: false }) public schema!: HaFormSchema[];
protected async getConfigForm(): Promise<LovelaceConfigForm | undefined> {
return { schema: this.schema };
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-form-element-editor": HuiFormElementEditor;
}
}

View File

@@ -12,7 +12,6 @@ import "./feature-editor/hui-card-feature-element-editor";
import "./header-footer-editor/hui-header-footer-element-editor"; import "./header-footer-editor/hui-header-footer-element-editor";
import "./heading-badge-editor/hui-heading-badge-element-editor"; import "./heading-badge-editor/hui-heading-badge-element-editor";
import type { HuiElementEditor } from "./hui-element-editor"; import type { HuiElementEditor } from "./hui-element-editor";
import "./hui-form-element-editor";
import "./picture-element-editor/hui-picture-element-element-editor"; import "./picture-element-editor/hui-picture-element-element-editor";
import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types"; import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types";
@@ -84,18 +83,6 @@ export class HuiSubElementEditor extends LitElement {
private _renderEditor() { private _renderEditor() {
const type = this.config.type; const type = this.config.type;
if (this.schema) {
return html`
<hui-form-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.schema=${this.schema}
.context=${this.config.context}
@config-changed=${this._handleConfigChanged}
></hui-form-element-editor>
`;
}
switch (type) { switch (type) {
case "row": case "row":
return html` return html`
@@ -104,6 +91,7 @@ export class HuiSubElementEditor extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this.config.elementConfig} .value=${this.config.elementConfig}
.context=${this.config.context} .context=${this.config.context}
.schema=${this.schema}
@config-changed=${this._handleConfigChanged} @config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged} @GUImode-changed=${this._handleGUIModeChanged}
></hui-row-element-editor> ></hui-row-element-editor>

View File

@@ -1,22 +0,0 @@
import { array, literal, object, string, union } from "superstruct";
const entityNameItemStruct = union([
object({
type: literal("text"),
text: string(),
}),
object({
type: union([
literal("entity"),
literal("device"),
literal("area"),
literal("floor"),
]),
}),
string(),
]);
export const entityNameStruct = union([
entityNameItemStruct,
array(entityNameItemStruct),
]);

View File

@@ -25,6 +25,10 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import {
applyViewTransitionOnLoad,
startViewTransition,
} from "../../common/dom/view_transition";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event"; import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { goBack, navigate } from "../../common/navigate"; import { goBack, navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize"; import type { LocalizeKeys } from "../../common/translations/localize";
@@ -72,7 +76,7 @@ import {
} from "../../dialogs/quick-bar/show-dialog-quick-bar"; } from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog"; import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles"; import { haStyle, haStyleViewTransitions } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types"; import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url"; import { documentationUrl } from "../../util/documentation-url";
import { showToast } from "../../util/toast"; import { showToast } from "../../util/toast";
@@ -318,7 +322,7 @@ class HUIRoot extends LitElement {
menu-corner="END" menu-corner="END"
> >
<ha-icon-button <ha-icon-button
.id="button-${index}" .label=${label}
.path=${item.icon} .path=${item.icon}
slot="trigger" slot="trigger"
></ha-icon-button> ></ha-icon-button>
@@ -340,9 +344,6 @@ class HUIRoot extends LitElement {
` `
)} )}
</ha-button-menu> </ha-button-menu>
<ha-tooltip placement="bottom" .for="button-${index}">
${label}
</ha-tooltip>
` `
: html` : html`
<ha-icon-button <ha-icon-button
@@ -622,6 +623,9 @@ class HUIRoot extends LitElement {
window.addEventListener("scroll", this._handleWindowScroll, { window.addEventListener("scroll", this._handleWindowScroll, {
passive: true, passive: true,
}); });
// Trigger view transition on initial load
applyViewTransitionOnLoad(this);
} }
public connectedCallback(): void { public connectedCallback(): void {
@@ -1161,43 +1165,45 @@ class HUIRoot extends LitElement {
// Recreate a new element to clear the applied themes. // Recreate a new element to clear the applied themes.
const root = this._viewRoot; const root = this._viewRoot;
if (root.lastChild) { startViewTransition(() => {
root.removeChild(root.lastChild); if (root.lastChild) {
} root.removeChild(root.lastChild);
}
if (viewIndex === "hass-unused-entities") { if (viewIndex === "hass-unused-entities") {
const unusedEntities = document.createElement("hui-unused-entities"); const unusedEntities = document.createElement("hui-unused-entities");
// Wait for promise to resolve so that the element has been upgraded. // Wait for promise to resolve so that the element has been upgraded.
import("./editor/unused-entities/hui-unused-entities").then(() => { import("./editor/unused-entities/hui-unused-entities").then(() => {
unusedEntities.hass = this.hass!; unusedEntities.hass = this.hass!;
unusedEntities.lovelace = this.lovelace!; unusedEntities.lovelace = this.lovelace!;
unusedEntities.narrow = this.narrow; unusedEntities.narrow = this.narrow;
}); });
root.appendChild(unusedEntities); root.appendChild(unusedEntities);
return; return;
} }
let view; let view;
const viewConfig = this.config.views[viewIndex]; const viewConfig = this.config.views[viewIndex];
if (!viewConfig) { if (!viewConfig) {
this.lovelace!.setEditMode(true); this.lovelace!.setEditMode(true);
return; return;
} }
if (!force && this._viewCache![viewIndex]) { if (!force && this._viewCache![viewIndex]) {
view = this._viewCache![viewIndex]; view = this._viewCache![viewIndex];
} else { } else {
view = document.createElement("hui-view"); view = document.createElement("hui-view");
view.index = viewIndex; view.index = viewIndex;
this._viewCache![viewIndex] = view; this._viewCache![viewIndex] = view;
} }
view.lovelace = this.lovelace; view.lovelace = this.lovelace;
view.hass = this.hass; view.hass = this.hass;
view.narrow = this.narrow; view.narrow = this.narrow;
root.appendChild(view); root.appendChild(view);
});
} }
private _openShortcutDialog(ev: Event) { private _openShortcutDialog(ev: Event) {
@@ -1208,6 +1214,7 @@ class HUIRoot extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
haStyleViewTransitions,
css` css`
:host { :host {
-ms-user-select: none; -ms-user-select: none;
@@ -1262,6 +1269,7 @@ class HUIRoot extends LitElement {
padding: 0px 12px; padding: 0px 12px;
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
box-sizing: border-box; box-sizing: border-box;
view-transition-name: lovelace-toolbar;
} }
.narrow .toolbar { .narrow .toolbar {
padding: 0 4px; padding: 0 4px;
@@ -1410,6 +1418,7 @@ class HUIRoot extends LitElement {
hui-view-container > * { hui-view-container > * {
flex: 1 1 100%; flex: 1 1 100%;
max-width: 100%; max-width: 100%;
view-transition-name: lovelace-view;
} }
/** /**
* In edit mode we have the tab bar on a new line * * In edit mode we have the tab bar on a new line *

View File

@@ -270,12 +270,15 @@ export class HomeAreaViewStrategy extends ReactiveElement {
})), })),
], ],
} satisfies HeadingCardConfig, } satisfies HeadingCardConfig,
...entities.map((e) => ({ ...entities.map((e) => {
...computeTileCard(e), const stateObj = hass.states[e];
name: { return {
type: "entity", ...computeTileCard(e),
}, name:
})), hass.formatEntityName(stateObj, "entity") ||
hass.formatEntityName(stateObj, "device"),
};
}),
], ],
}); });
} }

View File

@@ -161,9 +161,6 @@ export const haStyleDialog = css`
--mdc-dialog-min-height: 100svh; --mdc-dialog-min-height: 100svh;
--mdc-dialog-max-height: 100vh; --mdc-dialog-max-height: 100vh;
--mdc-dialog-max-height: 100svh; --mdc-dialog-max-height: 100svh;
--dialog-surface-padding: var(--safe-area-inset-top)
var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left);
--vertical-align-dialog: flex-end; --vertical-align-dialog: flex-end;
--ha-dialog-border-radius: var(--ha-border-radius-square); --ha-dialog-border-radius: var(--ha-border-radius-square);
} }
@@ -199,3 +196,58 @@ export const baseEntrypointStyles = css`
width: 100vw; width: 100vw;
} }
`; `;
export const haStyleViewTransitions = css`
@media (prefers-reduced-motion: no-preference) {
/* Toolbar fade in */
::view-transition-group(lovelace-toolbar) {
animation-duration: var(--ha-animation-duration);
animation-timing-function: ease-out;
}
::view-transition-new(lovelace-toolbar) {
animation: fade-in var(--ha-animation-duration) ease-out;
animation-delay: var(--ha-animation-delay-base);
}
/* View slide down */
::view-transition-group(lovelace-view) {
animation-duration: var(--ha-animation-duration);
animation-timing-function: ease-out;
}
::view-transition-new(lovelace-view) {
animation: fade-in-slide-down var(--ha-animation-duration) ease-out;
animation-delay: var(--ha-animation-delay-base);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-slide-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;

View File

@@ -152,10 +152,6 @@ export const semanticColorStyles = css`
--ha-color-on-success-quiet: var(--ha-color-green-50); --ha-color-on-success-quiet: var(--ha-color-green-50);
--ha-color-on-success-normal: var(--ha-color-green-40); --ha-color-on-success-normal: var(--ha-color-green-40);
--ha-color-on-success-loud: var(--white-color); --ha-color-on-success-loud: var(--white-color);
/* Surfaces */
--ha-color-surface-default: var(--ha-color-neutral-95);
--ha-color-on-surface-default: var(--ha-color-neutral-05);
} }
`; `;
@@ -284,9 +280,5 @@ export const darkSemanticColorStyles = css`
--ha-color-on-success-quiet: var(--ha-color-green-70); --ha-color-on-success-quiet: var(--ha-color-green-70);
--ha-color-on-success-normal: var(--ha-color-green-60); --ha-color-on-success-normal: var(--ha-color-green-60);
--ha-color-on-success-loud: var(--white-color); --ha-color-on-success-loud: var(--white-color);
/* Surfaces */
--ha-color-surface-default: var(--ha-color-neutral-10);
--ha-color-on-surface-default: var(--ha-color-neutral-95);
} }
`; `;

View File

@@ -52,13 +52,6 @@ export const waColorStyles = css`
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal); --wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet); --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-focus-ring-color: var(--ha-color-neutral-60);
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
} }
`; `;

View File

@@ -42,6 +42,24 @@ export const coreStyles = css`
--ha-space-18: 72px; --ha-space-18: 72px;
--ha-space-19: 76px; --ha-space-19: 76px;
--ha-space-20: 80px; --ha-space-20: 80px;
/* Animation timing */
--ha-animation-duration: 350ms;
--ha-animation-delay-base: 50ms;
}
@media (prefers-reduced-motion: reduce) {
html {
--ha-animation-duration: 150ms;
--ha-animation-delay-base: 20ms;
}
}
/* Enable View Transitions API for supported browsers */
@supports (view-transition-name: none) {
:root {
view-transition-name: root;
}
} }
`; `;

View File

@@ -16,7 +16,7 @@ export const waMainStyles = css`
--wa-font-weight-action: var(--ha-font-weight-medium); --wa-font-weight-action: var(--ha-font-weight-medium);
--wa-transition-fast: 75ms; --wa-transition-fast: 75ms;
--wa-transition-easing: ease; --wa-transition-easing: ease;
--wa-border-width-l: var(--ha-border-radius-lg); --wa-border-width-l: var(--ha-border-radius-l);
--wa-space-xl: 32px; --wa-space-xl: 32px;
} }

View File

@@ -1,5 +1,4 @@
import { ContextProvider } from "@lit/context"; import { ContextProvider } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
areasContext, areasContext,
configContext, configContext,
@@ -7,7 +6,6 @@ import {
devicesContext, devicesContext,
entitiesContext, entitiesContext,
floorsContext, floorsContext,
labelsContext,
localeContext, localeContext,
localizeContext, localizeContext,
panelsContext, panelsContext,
@@ -17,7 +15,6 @@ import {
userContext, userContext,
userDataContext, userDataContext,
} from "../data/context"; } from "../data/context";
import { subscribeLabelRegistry } from "../data/label_registry";
import type { Constructor, HomeAssistant } from "../types"; import type { Constructor, HomeAssistant } from "../types";
import type { HassBaseEl } from "./hass-base-mixin"; import type { HassBaseEl } from "./hass-base-mixin";
@@ -25,8 +22,6 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
superClass: T superClass: T
) => ) =>
class extends superClass { class extends superClass {
private _unsubscribeLabels?: UnsubscribeFunc;
private __contextProviders: Record< private __contextProviders: Record<
string, string,
ContextProvider<any> | undefined ContextProvider<any> | undefined
@@ -97,10 +92,6 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
context: floorsContext, context: floorsContext,
initialValue: this.hass ? this.hass.floors : this._pendingHass.floors, initialValue: this.hass ? this.hass.floors : this._pendingHass.floors,
}), }),
labels: new ContextProvider(this, {
context: labelsContext,
initialValue: [],
}),
}; };
protected hassConnected() { protected hassConnected() {
@@ -110,13 +101,6 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
this.__contextProviders[key]!.setValue(value); this.__contextProviders[key]!.setValue(value);
} }
} }
this._unsubscribeLabels = subscribeLabelRegistry(
this.hass!.connection!,
(labels) => {
this.__contextProviders.labels!.setValue(labels);
}
);
} }
protected _updateHass(obj: Partial<HomeAssistant>) { protected _updateHass(obj: Partial<HomeAssistant>) {
@@ -127,9 +111,4 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
} }
} }
} }
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeLabels?.();
}
}; };

View File

@@ -106,7 +106,8 @@
} }
}, },
"area": { "area": {
"area_not_found": "Area not found." "area_not_found": "Area not found.",
"media_playing": "Media playing"
}, },
"automation": { "automation": {
"last_triggered": "Last triggered", "last_triggered": "Last triggered",
@@ -646,7 +647,6 @@
}, },
"entity": { "entity": {
"entity-picker": { "entity-picker": {
"choose_entity": "Choose entity",
"entity": "Entity", "entity": "Entity",
"edit": "Edit", "edit": "Edit",
"clear": "Clear", "clear": "Clear",
@@ -657,18 +657,6 @@
"placeholder": "Select an entity", "placeholder": "Select an entity",
"create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper." "create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper."
}, },
"entity-name-picker": {
"types": {
"floor": "Floor",
"area": "Area",
"device": "Device",
"entity": "Entity",
"area_missing": "No area assigned",
"floor_missing": "No floor assigned",
"device_missing": "No related device"
},
"add": "Add"
},
"entity-attribute-picker": { "entity-attribute-picker": {
"attribute": "Attribute", "attribute": "Attribute",
"show_attributes": "Show attributes" "show_attributes": "Show attributes"
@@ -683,36 +671,16 @@
"expand_area_id": "Split this area into separate devices and entities.", "expand_area_id": "Split this area into separate devices and entities.",
"expand_device_id": "Split this device into separate entities.", "expand_device_id": "Split this device into separate entities.",
"expand_label_id": "Split this label into separate areas, devices and entities.", "expand_label_id": "Split this label into separate areas, devices and entities.",
"add_target": "Add target",
"remove": "Remove", "remove": "Remove",
"remove_floor_id": "Remove floor", "remove_floor_id": "Remove floor",
"remove_area_id": "Remove area", "remove_area_id": "Remove area",
"remove_device_id": "Remove device", "remove_device_id": "Remove device",
"remove_entity_id": "Remove entity", "remove_entity_id": "Remove entity",
"remove_label_id": "Remove label", "remove_label_id": "Remove label",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}", "add_area_id": "Choose area",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}", "add_device_id": "Choose device",
"target_details": "Target details", "add_entity_id": "Choose entity",
"no_targets": "No targets", "add_label_id": "Choose label"
"no_target_found": "No target found for {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": { "subpage-data-table": {
"filters": "Filters", "filters": "Filters",
@@ -5862,8 +5830,8 @@
"change_channel_initiated_text": "The channel change has been initiated and will complete in {delay} {delay, plural,\n one {minute}\n other {minutes}\n}.", "change_channel_initiated_text": "The channel change has been initiated and will complete in {delay} {delay, plural,\n one {minute}\n other {minutes}\n}.",
"change_channel_invalid": "Invalid channel", "change_channel_invalid": "Invalid channel",
"change_channel_label": "Channel", "change_channel_label": "Channel",
"change_channel_multiprotocol_enabled_title": "The Thread adapter has multiprotocol enabled", "change_channel_multiprotocol_enabled_title": "The Thread radio has multiprotocol enabled",
"change_channel_multiprotocol_enabled_text": "To change channel when the Thread adapter has multiprotocol enabled, please use the hardware settings menu.", "change_channel_multiprotocol_enabled_text": "To change channel when the Thread radio has multiprotocol enabled, please use the hardware settings menu.",
"change_channel_range": "Channel must be in the range 11 to 26", "change_channel_range": "Channel must be in the range 11 to 26",
"change_channel_text": "Initiating a channel change for your Home Assistant Thread network should be performed with caution. Some Thread devices may not migrate to the new channel automatically and, if the new channel is congested, your Thread devices may become intermittently unavailable. Some devices may need to be manually re-joined to your Thread network before they show in Home Assistant again. This action cannot be reversed (without performing another channel change).", "change_channel_text": "Initiating a channel change for your Home Assistant Thread network should be performed with caution. Some Thread devices may not migrate to the new channel automatically and, if the new channel is congested, your Thread devices may become intermittently unavailable. Some devices may need to be manually re-joined to your Thread network before they show in Home Assistant again. This action cannot be reversed (without performing another channel change).",
"thread_network_info": "Thread network information", "thread_network_info": "Thread network information",
@@ -5908,12 +5876,12 @@
"devices_offline": "{count} offline", "devices_offline": "{count} offline",
"update_button": "Update configuration", "update_button": "Update configuration",
"download_backup": "Download backup", "download_backup": "Download backup",
"migrate_radio": "Migrate adapter", "migrate_radio": "Migrate radio",
"network_settings_title": "Network settings", "network_settings_title": "Network settings",
"change_channel": "Change channel", "change_channel": "Change channel",
"channel_dialog": { "channel_dialog": {
"title": "Multiprotocol add-on in use", "title": "Multiprotocol add-on in use",
"text": "Zigbee and Thread share the same adapter and must use the same channel. Change the channel of both networks by reconfiguring multiprotocol from the hardware menu." "text": "Zigbee and Thread share the same radio and must use the same channel. Change the channel of both networks by reconfiguring multiprotocol from the hardware menu."
} }
}, },
"add_device_page": { "add_device_page": {
@@ -7955,8 +7923,6 @@
"integration_not_loaded": "This card requires the `todo` integration to be set up.", "integration_not_loaded": "This card requires the `todo` integration to be set up.",
"hide_completed": "Hide completed items", "hide_completed": "Hide completed items",
"hide_create": "Hide 'Add item' field", "hide_create": "Hide 'Add item' field",
"hide_section_headers": "Hide section headers",
"hide_section_headers_helper": "Removes the 'Active' and 'Completed' sections with the overflow menus.",
"display_order": "Display order", "display_order": "Display order",
"item_tap_action": "Item tap behavior", "item_tap_action": "Item tap behavior",
"actions": { "actions": {

View File

@@ -9,10 +9,7 @@ import type {
HassServiceTarget, HassServiceTarget,
MessageBase, MessageBase,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import type { import type { EntityNameType } from "./common/translations/entity-state";
EntityNameItem,
EntityNameOptions,
} from "./common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "./common/translations/localize"; import type { LocalizeFunc } from "./common/translations/localize";
import type { AreaRegistryEntry } from "./data/area_registry"; import type { AreaRegistryEntry } from "./data/area_registry";
import type { DeviceRegistryEntry } from "./data/device_registry"; import type { DeviceRegistryEntry } from "./data/device_registry";
@@ -291,8 +288,8 @@ export interface HomeAssistant {
formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; formatEntityAttributeName(stateObj: HassEntity, attribute: string): string;
formatEntityName( formatEntityName(
stateObj: HassEntity, stateObj: HassEntity,
type: EntityNameItem | EntityNameItem[], type: EntityNameType | EntityNameType[],
separator?: EntityNameOptions separator?: string
): string; ): string;
} }

View File

@@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import {
computeActiveAreaMediaStates,
type MediaPlayerEntity,
} from "../../../src/data/media-player";
describe("computeActiveAreaMediaStates", () => {
it("returns playing media entities in the area", () => {
const hass = {
areas: { living_room: { area_id: "living_room" } },
entities: {
"media_player.tv": {
entity_id: "media_player.tv",
area_id: "living_room",
},
"media_player.speaker": {
entity_id: "media_player.speaker",
area_id: "living_room",
},
},
states: {
"media_player.tv": {
entity_id: "media_player.tv",
state: "playing",
} as MediaPlayerEntity,
"media_player.speaker": {
entity_id: "media_player.speaker",
state: "idle",
} as MediaPlayerEntity,
},
} as any;
const result = computeActiveAreaMediaStates(hass, "living_room");
expect(result).toHaveLength(1);
expect(result[0].entity_id).toBe("media_player.tv");
expect(result[0].state).toBe("playing");
});
it("returns empty array when no area is configured", () => {
const hass = {
areas: {},
entities: {},
states: {},
} as any;
const result = computeActiveAreaMediaStates(hass, "living_room");
expect(result).toHaveLength(0);
});
it("returns empty array when media player is not assigned to area", () => {
const hass = {
areas: { living_room: { area_id: "living_room" } },
entities: {
"media_player.bedroom": { entity_id: "media_player.bedroom" },
},
states: {
"media_player.bedroom": {
entity_id: "media_player.bedroom",
state: "playing",
} as MediaPlayerEntity,
},
} as any;
const result = computeActiveAreaMediaStates(hass, "living_room");
expect(result).toHaveLength(0);
});
it("returns playing speaker when speaker is playing", () => {
const hass = {
areas: { living_room: { area_id: "living_room" } },
entities: {
"media_player.tv": {
entity_id: "media_player.tv",
area_id: "living_room",
},
"media_player.speaker": {
entity_id: "media_player.speaker",
area_id: "living_room",
},
},
states: {
"media_player.tv": {
entity_id: "media_player.tv",
state: "idle",
} as MediaPlayerEntity,
"media_player.speaker": {
entity_id: "media_player.speaker",
state: "playing",
} as MediaPlayerEntity,
},
} as any;
const result = computeActiveAreaMediaStates(hass, "living_room");
expect(result).toHaveLength(1);
expect(result[0].entity_id).toBe("media_player.speaker");
expect(result[0].state).toBe("playing");
});
it("returns media entities that inherit area from device", () => {
const hass = {
areas: { living_room: { area_id: "living_room" } },
devices: {
device_tv: {
id: "device_tv",
area_id: "living_room",
},
},
entities: {
"media_player.tv": {
entity_id: "media_player.tv",
device_id: "device_tv", // Entity belongs to device
// No direct area_id - inherits from device
},
},
states: {
"media_player.tv": {
entity_id: "media_player.tv",
state: "playing",
} as MediaPlayerEntity,
},
} as any;
const result = computeActiveAreaMediaStates(hass, "living_room");
expect(result).toHaveLength(1);
expect(result[0].entity_id).toBe("media_player.tv");
expect(result[0].state).toBe("playing");
});
});
describe("computeActiveAreaMediaStates badge priority", () => {
it("prioritizes alert badge over media badge", () => {
const hass = {
areas: { living_room: { area_id: "living_room" } },
entities: {
"binary_sensor.door": {
entity_id: "binary_sensor.door",
area_id: "living_room",
},
"media_player.tv": {
entity_id: "media_player.tv",
area_id: "living_room",
},
},
states: {
"binary_sensor.door": {
entity_id: "binary_sensor.door",
state: "on",
} as MediaPlayerEntity,
"media_player.tv": {
entity_id: "media_player.tv",
state: "playing",
} as MediaPlayerEntity,
},
} as any;
const alertStates = hass.states["binary_sensor.door"]
? [hass.states["binary_sensor.door"]]
: [];
const mediaStates = computeActiveAreaMediaStates(hass, "living_room");
// Alert badge should take priority
expect(alertStates.length > 0).toBe(true);
expect(mediaStates.length > 0).toBe(true);
expect(alertStates.length > 0 ? "alert" : "media").toBe("alert");
});
it("shows media badge when no alerts", () => {
const hass = {
areas: { living_room: { area_id: "living_room" } },
entities: {
"media_player.tv": {
entity_id: "media_player.tv",
area_id: "living_room",
},
},
states: {
"media_player.tv": {
entity_id: "media_player.tv",
state: "playing",
} as MediaPlayerEntity,
},
} as any;
const alertStates: MediaPlayerEntity[] = [];
const mediaStates = computeActiveAreaMediaStates(hass, "living_room");
expect(alertStates.length).toBe(0);
expect(mediaStates.length).toBe(1);
expect(alertStates.length > 0 ? "alert" : "media").toBe("media");
});
});

View File

@@ -1,408 +0,0 @@
import { describe, expect, it } from "vitest";
import {
computeEntityNameDisplay,
computeEntityNameList,
} from "../../../src/common/entity/compute_entity_name_display";
import type { HomeAssistant } from "../../../src/types";
import {
mockArea,
mockDevice,
mockEntity,
mockFloor,
mockStateObj,
} from "./context/context-mock";
describe("computeEntityNameDisplay", () => {
it("returns text when all items are text", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {},
devices: {},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Hello World");
});
it("uses custom separator for text items", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {},
devices: {},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors,
{ separator: " - " }
);
expect(result).toBe("Hello - World");
});
it("returns entity name", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Kitchen Light",
}),
},
devices: {},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
{ type: "entity" },
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Kitchen Light");
});
it("replaces entity with device name when entity uses device name", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Kitchen Device",
device_id: "dev1",
}),
},
devices: {
dev1: mockDevice({
id: "dev1",
name: "Kitchen Device",
}),
},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
{ type: "entity" },
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Kitchen Device");
});
it("does not replace entity with device when device is already included", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Kitchen Device",
device_id: "dev1",
}),
},
devices: {
dev1: mockDevice({
id: "dev1",
name: "Kitchen Device",
}),
},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[{ type: "entity" }, { type: "device" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
// Since entity name equals device name, entity returns undefined
// So we only get the device name
expect(result).toBe("Kitchen Device");
});
it("returns combined entity and area names", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Ceiling Light",
area_id: "kitchen",
}),
},
devices: {},
areas: {
kitchen: mockArea({
area_id: "kitchen",
name: "Kitchen",
}),
},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[{ type: "area" }, { type: "entity" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Kitchen Ceiling Light");
});
it("returns combined device and area names", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
device_id: "dev1",
}),
},
devices: {
dev1: mockDevice({
id: "dev1",
name: "Smart Light",
area_id: "kitchen",
}),
},
areas: {
kitchen: mockArea({
area_id: "kitchen",
name: "Kitchen",
}),
},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[{ type: "area" }, { type: "device" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Kitchen Smart Light");
});
it("returns floor name", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
area_id: "kitchen",
}),
},
devices: {},
areas: {
kitchen: mockArea({
area_id: "kitchen",
name: "Kitchen",
floor_id: "first",
}),
},
floors: {
first: mockFloor({
floor_id: "first",
name: "First Floor",
}),
},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
{ type: "floor" },
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("First Floor");
});
it("filters out undefined names when combining", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
}),
},
devices: {},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[{ type: "area" }, { type: "entity" }, { type: "floor" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
// Area and floor don't exist, so only entity name is included
expect(result).toBe("Light");
});
it("mixes text with entity types", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
area_id: "kitchen",
}),
},
devices: {},
areas: {
kitchen: mockArea({
area_id: "kitchen",
name: "Kitchen",
}),
},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[{ type: "area" }, { type: "text", text: "-" }, { type: "entity" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Kitchen - Light");
});
});
describe("computeEntityNameList", () => {
it("returns list of names for each item type", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
device_id: "dev1",
area_id: "kitchen",
}),
},
devices: {
dev1: mockDevice({
id: "dev1",
name: "Smart Device",
area_id: "kitchen",
}),
},
areas: {
kitchen: mockArea({
area_id: "kitchen",
name: "Kitchen",
floor_id: "first",
}),
},
floors: {
first: mockFloor({
floor_id: "first",
name: "First Floor",
}),
},
} as unknown as HomeAssistant;
const result = computeEntityNameList(
stateObj,
[
{ type: "floor" },
{ type: "area" },
{ type: "device" },
{ type: "entity" },
{ type: "text", text: "Custom" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toEqual([
"First Floor",
"Kitchen",
"Smart Device",
"Light",
"Custom",
]);
});
it("returns undefined for missing context items", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
entities: {
"light.kitchen": mockEntity({
entity_id: "light.kitchen",
name: "Light",
}),
},
devices: {},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameList(
stateObj,
[{ type: "device" }, { type: "area" }, { type: "floor" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toEqual([undefined, undefined, undefined]);
});
});

View File

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

156
yarn.lock
View File

@@ -1942,9 +1942,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@home-assistant/webawesome@npm:3.0.0-beta.6.ha.4": "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.1":
version: 3.0.0-beta.6.ha.4 version: 3.0.0-beta.6.ha.1
resolution: "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.4" resolution: "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.1"
dependencies: dependencies:
"@ctrl/tinycolor": "npm:4.1.0" "@ctrl/tinycolor": "npm:4.1.0"
"@floating-ui/dom": "npm:^1.6.13" "@floating-ui/dom": "npm:^1.6.13"
@@ -1955,7 +1955,7 @@ __metadata:
lit: "npm:^3.2.1" lit: "npm:^3.2.1"
nanoid: "npm:^5.1.5" nanoid: "npm:^5.1.5"
qr-creator: "npm:^1.0.0" qr-creator: "npm:^1.0.0"
checksum: 10/d9072b321126ef458468ed2cf040e0b04cb2aff73336c6e742c0cfb25d9fb674b7672e7c9abcf5bcb0aa0b2fe953c20186f0910f485024c827bfe4cf399f10a4 checksum: 10/c9510e0c65b682c3868b5cbbf046f62aea30e3c5d969128d9032e0d89a8943faa4c9d78c3500446ec04cffeb0ab1939b870b60d454db657faed2aa0ac6026a3e
languageName: node languageName: node
linkType: hard linkType: hard
@@ -4945,106 +4945,106 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.46.0": "@typescript-eslint/eslint-plugin@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.46.0" resolution: "@typescript-eslint/eslint-plugin@npm:8.45.0"
dependencies: dependencies:
"@eslint-community/regexpp": "npm:^4.10.0" "@eslint-community/regexpp": "npm:^4.10.0"
"@typescript-eslint/scope-manager": "npm:8.46.0" "@typescript-eslint/scope-manager": "npm:8.45.0"
"@typescript-eslint/type-utils": "npm:8.46.0" "@typescript-eslint/type-utils": "npm:8.45.0"
"@typescript-eslint/utils": "npm:8.46.0" "@typescript-eslint/utils": "npm:8.45.0"
"@typescript-eslint/visitor-keys": "npm:8.46.0" "@typescript-eslint/visitor-keys": "npm:8.45.0"
graphemer: "npm:^1.4.0" graphemer: "npm:^1.4.0"
ignore: "npm:^7.0.0" ignore: "npm:^7.0.0"
natural-compare: "npm:^1.4.0" natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.1.0" ts-api-utils: "npm:^2.1.0"
peerDependencies: peerDependencies:
"@typescript-eslint/parser": ^8.46.0 "@typescript-eslint/parser": ^8.45.0
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/415afd894a5fec9cfe2c327c8b26377045979cc6bdf720aeecb32af335b9e6865c70fa6a355dd16f52a36dc38f50755df3eb1466d5822c53c80465ff824c9881 checksum: 10/6d31dbd3354028b4a010af0ea2614a171b11616e6f20d36d74529b8888681ae8d15e1269122b8a8d5fae117bdd66dac4a38cfc99dc2a0ee33bd22c10075f63e4
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/parser@npm:8.46.0": "@typescript-eslint/parser@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/parser@npm:8.46.0" resolution: "@typescript-eslint/parser@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/scope-manager": "npm:8.46.0" "@typescript-eslint/scope-manager": "npm:8.45.0"
"@typescript-eslint/types": "npm:8.46.0" "@typescript-eslint/types": "npm:8.45.0"
"@typescript-eslint/typescript-estree": "npm:8.46.0" "@typescript-eslint/typescript-estree": "npm:8.45.0"
"@typescript-eslint/visitor-keys": "npm:8.46.0" "@typescript-eslint/visitor-keys": "npm:8.45.0"
debug: "npm:^4.3.4" debug: "npm:^4.3.4"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/6838fde776fd2b2932b259a20cc89b517e0c94a2cfa363a5e8531095c23fb35d8f803196f6594026d0510bf2a8ec003c67181bb2c407904685a64c97602da65f checksum: 10/4f8b7c73ae3b53c2adc4e981ac2ca90839a118947635481b45d29423d39b7b73cde2b185ad1084c9e19c3239444bf1be81f40b861176eec4540cb46848731991
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/project-service@npm:8.46.0": "@typescript-eslint/project-service@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/project-service@npm:8.46.0" resolution: "@typescript-eslint/project-service@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.46.0" "@typescript-eslint/tsconfig-utils": "npm:^8.45.0"
"@typescript-eslint/types": "npm:^8.46.0" "@typescript-eslint/types": "npm:^8.45.0"
debug: "npm:^4.3.4" debug: "npm:^4.3.4"
peerDependencies: peerDependencies:
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/de11af23ae6b82769b667e8d6e81d47ce039c7817465b99c1e29c8fbcac58af898bebe70368a274cd7b3c7232354134d53ceba0415b8d7e18317037bc4a4a2f7 checksum: 10/919c8260dae79eaec79de84a5ae66fbb09c2ab7aca8c3b7785cb011582a2864c8091e64c84013b05bce812e522fbc4a5ae1c68f86404e078fc84da0fe80247ce
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/scope-manager@npm:8.46.0": "@typescript-eslint/scope-manager@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/scope-manager@npm:8.46.0" resolution: "@typescript-eslint/scope-manager@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/types": "npm:8.46.0" "@typescript-eslint/types": "npm:8.45.0"
"@typescript-eslint/visitor-keys": "npm:8.46.0" "@typescript-eslint/visitor-keys": "npm:8.45.0"
checksum: 10/ed85abd08c0edf088b1b11757c658acf593cf84051bddde651304a609d3a6cd9e331149e88653676606a565c3f92c191d4af049f540f6e3bb692a4f38305fd71 checksum: 10/e45d63a0109eca00f6b431d87e73eacfa03b1795905f123e9144bcacb5abb83888167d1849317c6f90ba1f3553196b2eab13e5e7cdd1050d7a84eaadb65ba801
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.46.0, @typescript-eslint/tsconfig-utils@npm:^8.46.0": "@typescript-eslint/tsconfig-utils@npm:8.45.0, @typescript-eslint/tsconfig-utils@npm:^8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.0" resolution: "@typescript-eslint/tsconfig-utils@npm:8.45.0"
peerDependencies: peerDependencies:
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/e78a66a854322423aca835070c5ee9489975c4d80d2f8ffe9cf4d6e3f67a1646ddc05b086f7156599c90ad521670ca572a4315f2b49a5922c33d6e49723558e4 checksum: 10/91696bbc34758749d3647236986bf418bacdc0de0e27c2d39cd7c2408c404c35ed18c47c2a55aea0bb9525cc7eb656586359c4e651144603f3438ce93fe80081
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/type-utils@npm:8.46.0": "@typescript-eslint/type-utils@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/type-utils@npm:8.46.0" resolution: "@typescript-eslint/type-utils@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/types": "npm:8.46.0" "@typescript-eslint/types": "npm:8.45.0"
"@typescript-eslint/typescript-estree": "npm:8.46.0" "@typescript-eslint/typescript-estree": "npm:8.45.0"
"@typescript-eslint/utils": "npm:8.46.0" "@typescript-eslint/utils": "npm:8.45.0"
debug: "npm:^4.3.4" debug: "npm:^4.3.4"
ts-api-utils: "npm:^2.1.0" ts-api-utils: "npm:^2.1.0"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/5405b71b91d02ed4eac1028fc156c053953403b9f48393d92340b15a8b05bee5bf1281324c6283ac31a0e03cc1a19baf94768cb3fd70b4621f8c07a4243837db checksum: 10/81017b3f4780a65a4e4268ab208f1cb8891c1ced9ade23d8eb4575b18aeb99fe59a0d0ddbb4eea9c086567a1b4515d3466e850d4c81ec0d2d88658c43877a6cf
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/types@npm:8.46.0, @typescript-eslint/types@npm:^8.46.0": "@typescript-eslint/types@npm:8.45.0, @typescript-eslint/types@npm:^8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/types@npm:8.46.0" resolution: "@typescript-eslint/types@npm:8.45.0"
checksum: 10/0118b0dd592bf4beaf41e8c6be812980dd0adea44d48c90d8b0272777b58d4cfd6326b8bc363efa3c640be476a6bf3632aee2d97052d5e34071e6576b9c28264 checksum: 10/889ded2b9bf376c876611b2a37f89051fdc8ec501314a4b97832caefa4305bffc4b752548941ce2e7f9659a81336d096d439d4c2ed236c99fefdf60b715593dd
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/typescript-estree@npm:8.46.0": "@typescript-eslint/typescript-estree@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/typescript-estree@npm:8.46.0" resolution: "@typescript-eslint/typescript-estree@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/project-service": "npm:8.46.0" "@typescript-eslint/project-service": "npm:8.45.0"
"@typescript-eslint/tsconfig-utils": "npm:8.46.0" "@typescript-eslint/tsconfig-utils": "npm:8.45.0"
"@typescript-eslint/types": "npm:8.46.0" "@typescript-eslint/types": "npm:8.45.0"
"@typescript-eslint/visitor-keys": "npm:8.46.0" "@typescript-eslint/visitor-keys": "npm:8.45.0"
debug: "npm:^4.3.4" debug: "npm:^4.3.4"
fast-glob: "npm:^3.3.2" fast-glob: "npm:^3.3.2"
is-glob: "npm:^4.0.3" is-glob: "npm:^4.0.3"
@@ -5053,32 +5053,32 @@ __metadata:
ts-api-utils: "npm:^2.1.0" ts-api-utils: "npm:^2.1.0"
peerDependencies: peerDependencies:
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/61053bd0c35a1fe5c82aef00cb70dbe0878ab28e55550cc1e2d6e7d4a0520c81947eb7505227c85a742a93db905d7e7376aed7d958dc257507b9bdda1daf0b00 checksum: 10/2fb4e63ad6128afbada8eabaabfe7d5a8f1a1f387bb13d7d3209103493ba974b518bf47b17e9a853beba10ec81efd5582ebf628c2eb77a924cf67d4d85466e5e
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/utils@npm:8.46.0": "@typescript-eslint/utils@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/utils@npm:8.46.0" resolution: "@typescript-eslint/utils@npm:8.45.0"
dependencies: dependencies:
"@eslint-community/eslint-utils": "npm:^4.7.0" "@eslint-community/eslint-utils": "npm:^4.7.0"
"@typescript-eslint/scope-manager": "npm:8.46.0" "@typescript-eslint/scope-manager": "npm:8.45.0"
"@typescript-eslint/types": "npm:8.46.0" "@typescript-eslint/types": "npm:8.45.0"
"@typescript-eslint/typescript-estree": "npm:8.46.0" "@typescript-eslint/typescript-estree": "npm:8.45.0"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/4e0da60de389799afdd36249fd4bcf9e085a4d6f119e241e436a701b45cdf10becc3f1e3cdef29ebbf147a81f40d9a4800d428cb4a66799d3e4aa80b879c9ee2 checksum: 10/9e675a0da4434bd434901f9ba3e1e91d4d7ad542d7fcf8c23534a67f2f9039a569da20929e67a6562e3a263be226ad424cd0c1ac80f7828f4285f7f34e361926
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/visitor-keys@npm:8.46.0": "@typescript-eslint/visitor-keys@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "@typescript-eslint/visitor-keys@npm:8.46.0" resolution: "@typescript-eslint/visitor-keys@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/types": "npm:8.46.0" "@typescript-eslint/types": "npm:8.45.0"
eslint-visitor-keys: "npm:^4.2.1" eslint-visitor-keys: "npm:^4.2.1"
checksum: 10/37e6145b6a5e960c59777d7fc86f722ff696e76c627106ac4577b945ca35744a5f96525d77bde50fe8c328503e9392e21e3adb7cf9899ae0efc054d63f4c3916 checksum: 10/8ae7e19c69c1f67fa8f952c18a09ad42a8cba492545d6e1dca6750e760893773f69ec6b1a96d0997e833c82aecc5ff7fb9546c5abd6c4427d91206670cf8ff37
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9207,7 +9207,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.19" "@fullcalendar/list": "npm:6.1.19"
"@fullcalendar/luxon3": "npm:6.1.19" "@fullcalendar/luxon3": "npm:6.1.19"
"@fullcalendar/timegrid": "npm:6.1.19" "@fullcalendar/timegrid": "npm:6.1.19"
"@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.4" "@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.1"
"@lezer/highlight": "npm:1.2.1" "@lezer/highlight": "npm:1.2.1"
"@lit-labs/motion": "npm:1.0.9" "@lit-labs/motion": "npm:1.0.9"
"@lit-labs/observers": "npm:2.0.6" "@lit-labs/observers": "npm:2.0.6"
@@ -9354,7 +9354,7 @@ __metadata:
tinykeys: "npm:3.0.0" tinykeys: "npm:3.0.0"
ts-lit-plugin: "npm:2.0.2" ts-lit-plugin: "npm:2.0.2"
typescript: "npm:5.9.3" typescript: "npm:5.9.3"
typescript-eslint: "npm:8.46.0" typescript-eslint: "npm:8.45.0"
ua-parser-js: "npm:2.0.5" ua-parser-js: "npm:2.0.5"
vite-tsconfig-paths: "npm:5.1.4" vite-tsconfig-paths: "npm:5.1.4"
vitest: "npm:3.2.4" vitest: "npm:3.2.4"
@@ -14317,18 +14317,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"typescript-eslint@npm:8.46.0": "typescript-eslint@npm:8.45.0":
version: 8.46.0 version: 8.45.0
resolution: "typescript-eslint@npm:8.46.0" resolution: "typescript-eslint@npm:8.45.0"
dependencies: dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.46.0" "@typescript-eslint/eslint-plugin": "npm:8.45.0"
"@typescript-eslint/parser": "npm:8.46.0" "@typescript-eslint/parser": "npm:8.45.0"
"@typescript-eslint/typescript-estree": "npm:8.46.0" "@typescript-eslint/typescript-estree": "npm:8.45.0"
"@typescript-eslint/utils": "npm:8.46.0" "@typescript-eslint/utils": "npm:8.45.0"
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0" typescript: ">=4.8.4 <6.0.0"
checksum: 10/fd74aab1d21d661299a64107236b5c3515d6d955eb1764b56c5c9505b8cef5f2600e8290d251f1379138333573df94a1fe1fd7fef23952b5ab9f12ff2b774f92 checksum: 10/1c17ebb5bcbea418c8f372d71b5c2df8c9b8c6897d1bda8196ea17bac8fabeffe1814bc4f7a28d40f404fb811c97fcda0d69c4375b4f010d9bf44d19d8401706
languageName: node languageName: node
linkType: hard linkType: hard