Compare commits

..

2 Commits

Author SHA1 Message Date
Bram Kragten
1960680191 Merge branch 'dev' into triggers-descriptions 2025-10-06 15:46:03 +02:00
Bram Kragten
a4ddb71484 Add support for triggers.yaml 2025-10-06 15:38:37 +02:00
112 changed files with 3474 additions and 7246 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

@@ -37,22 +37,22 @@
"@codemirror/view": "6.38.4",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.1",
"@formatjs/intl-displaynames": "6.8.12",
"@formatjs/intl-durationformat": "0.7.5",
"@formatjs/intl-getcanonicallocales": "2.5.6",
"@formatjs/intl-listformat": "7.7.12",
"@formatjs/intl-locale": "4.2.12",
"@formatjs/intl-numberformat": "8.15.5",
"@formatjs/intl-pluralrules": "5.4.5",
"@formatjs/intl-relativetimeformat": "11.4.12",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
"@formatjs/intl-durationformat": "0.7.4",
"@formatjs/intl-getcanonicallocales": "2.5.5",
"@formatjs/intl-listformat": "7.7.11",
"@formatjs/intl-locale": "4.2.11",
"@formatjs/intl-numberformat": "8.15.4",
"@formatjs/intl-pluralrules": "5.4.4",
"@formatjs/intl-relativetimeformat": "11.4.11",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/interaction": "6.1.19",
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.3",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.1",
"@lezer/highlight": "1.2.1",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -114,7 +114,7 @@
"hls.js": "1.6.13",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.17",
"intl-messageformat": "10.7.16",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -183,7 +183,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.37.0",
"eslint": "9.36.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",

View File

@@ -1,141 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
const UNDO_REDO_STACK_LIMIT = 75;
/**
* Configuration options for the UndoRedoController.
*
* @template ConfigType The type of configuration to manage.
*/
export interface UndoRedoControllerConfig<ConfigType> {
stackLimit?: number;
currentConfig: () => ConfigType;
apply: (config: ConfigType) => void;
}
/**
* A controller to manage undo and redo operations for a given configuration type.
*
* @template ConfigType The type of configuration to manage.
*/
export class UndoRedoController<ConfigType> implements ReactiveController {
private _host: ReactiveControllerHost;
private _undoStack: ConfigType[] = [];
private _redoStack: ConfigType[] = [];
private readonly _stackLimit: number = UNDO_REDO_STACK_LIMIT;
private readonly _apply: (config: ConfigType) => void = () => {
throw new Error("No apply function provided");
};
private readonly _currentConfig: () => ConfigType = () => {
throw new Error("No currentConfig function provided");
};
constructor(
host: ReactiveControllerHost,
options: UndoRedoControllerConfig<ConfigType>
) {
if (options.stackLimit !== undefined) {
this._stackLimit = options.stackLimit;
}
this._apply = options.apply;
this._currentConfig = options.currentConfig;
this._host = host;
host.addController(this);
}
hostConnected() {
window.addEventListener("undo-change", this._onUndoChange);
}
hostDisconnected() {
window.removeEventListener("undo-change", this._onUndoChange);
}
private _onUndoChange = (ev: Event) => {
ev.stopPropagation();
this.undo();
this._host.requestUpdate();
};
/**
* Indicates whether there are actions available to undo.
*
* @returns `true` if there are actions to undo, `false` otherwise.
*/
public get canUndo(): boolean {
return this._undoStack.length > 0;
}
/**
* Indicates whether there are actions available to redo.
*
* @returns `true` if there are actions to redo, `false` otherwise.
*/
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
/**
* Commits the current configuration to the undo stack and clears the redo stack.
*
* @param config The current configuration to commit.
*/
public commit(config: ConfigType) {
if (this._undoStack.length >= this._stackLimit) {
this._undoStack.shift();
}
this._undoStack.push({ ...config });
this._redoStack = [];
}
/**
* Undoes the last action and applies the previous configuration
* while saving the current configuration to the redo stack.
*/
public undo() {
if (this._undoStack.length === 0) {
return;
}
this._redoStack.push({ ...this._currentConfig() });
const config = this._undoStack.pop()!;
this._apply(config);
this._host.requestUpdate();
}
/**
* Redoes the last undone action and reapplies the configuration
* while saving the current configuration to the undo stack.
*/
public redo() {
if (this._redoStack.length === 0) {
return;
}
this._undoStack.push({ ...this._currentConfig() });
const config = this._redoStack.pop()!;
this._apply(config);
this._host.requestUpdate();
}
/**
* Resets the undo and redo stacks, clearing all history.
*/
public reset() {
this._undoStack = [];
this._redoStack = [];
}
}
declare global {
interface HASSDomEvents {
"undo-change": undefined;
}
}

View File

@@ -61,9 +61,3 @@ export const computeEntityEntryName = (
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 = (
area: AreaRegistryEntry,
hassFloors: HomeAssistant["floors"]
hass: HomeAssistant
): AreaContext => {
const floorId = area.floor_id;
const floor = floorId ? hassFloors[floorId] : undefined;
const floor = floorId ? hass.floors[floorId] : undefined;
return {
area: area,

View File

@@ -1,12 +1,13 @@
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistant } from "../../types";
import {
computeEntityNameDisplay,
type EntityNameItem,
type EntityNameOptions,
} from "../entity/compute_entity_name_display";
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 = (
stateObj: HassEntity,
@@ -26,8 +27,8 @@ export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
options?: EntityNameOptions
type: EntityNameType | EntityNameType[],
separator?: string
) => string;
export const computeFormatFunctions = async (
@@ -74,15 +75,45 @@ export const computeFormatFunctions = async (
),
formatEntityAttributeName: (stateObj, attribute) =>
computeAttributeNameDisplay(localize, stateObj, entities, attribute),
formatEntityName: (stateObj, name, options) =>
computeEntityNameDisplay(
formatEntityName: (stateObj, type, separator = " ") => {
const types = ensureArray(type);
const namesList: (string | undefined)[] = [];
const { device, area, floor } = getEntityContext(
stateObj,
name,
entities,
devices,
areas,
floors,
options
),
floors
);
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 Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stringCompare, ipCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { HaFuse } from "../../resources/fuse";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,48 +8,29 @@ import type {
SortingDirection,
} 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 = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
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 === "") {
return data;
}
const index = fuseIndex(data, columns);
const fuse = new HaFuse(
data,
{ shouldSort: false, minMatchCharLength: 1 },
index
if (stripDiacritics(value).toLowerCase().includes(filter)) {
return true;
}
}
return false;
})
);
const searchResults = fuse.multiTermsSearch(filter);
if (searchResults) {
return searchResults.map((result) => result.item);
}
return data;
};
const sortData = (

View File

@@ -5,18 +5,24 @@ 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 } 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 { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
getDevices,
type DevicePickerItem,
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "../../data/device_registry";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -24,6 +30,11 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
@customElement("ha-device-picker")
export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -93,8 +104,6 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
private _getDevicesMemoized = memoizeOne(getDevices);
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
@@ -108,18 +117,162 @@ export class HaDevicePicker extends LitElement {
}
private _getItems = () =>
this._getDevicesMemoized(
this.hass,
this._getDevices(
this.hass.devices,
this.hass.entities,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices,
this.value
this.excludeDevices
);
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(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
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 { computeRTL } from "../../common/util/compute_rtl";
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[] = [];
@@ -257,15 +256,9 @@ export class HaStatisticPicker extends LitElement {
const id = meta.statistic_id;
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 entityName = hass.formatEntityName(stateObj, "entity");
const deviceName = hass.formatEntityName(stateObj, "device");
const areaName = hass.formatEntityName(stateObj, "area");
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
@@ -338,14 +331,9 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const isRTL = computeRTL(this.hass);

View File

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

View File

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

View File

@@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const { floor } = getAreaContext(area, this.hass!);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
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 "@home-assistant/webawesome/dist/components/drawer/drawer";
import { customElement, property, state } from "lit/decorators";
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 {
@property({ type: Boolean }) public open = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@state() private _drawerOpen = false;
private _handleAfterHide() {
@@ -44,19 +41,16 @@ export class HaBottomSheet extends LitElement {
static styles = css`
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;
--size: var(--ha-bottom-sheet-height, auto);
--size: auto;
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
}
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(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
@@ -65,19 +59,10 @@ export class HaBottomSheet extends LitElement {
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
background-color: var(
--ha-bottom-sheet-surface-background,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
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;
max-height: 90vh;
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
`;
}

View File

@@ -41,7 +41,8 @@ export class HaButton extends Button {
return [
Button.styles,
css`
:host {
.button {
/* set theme vars */
--wa-form-control-padding-inline: 16px;
--wa-font-weight-action: var(--ha-font-weight-medium);
--wa-form-control-border-radius: var(
@@ -53,8 +54,7 @@ export class HaButton extends Button {
--ha-button-height,
var(--button-height, 40px)
);
}
.button {
font-size: var(--ha-font-size-m);
line-height: 1;
@@ -223,12 +223,6 @@ export class HaButton extends Button {
.button.has-end {
padding-inline-end: 8px;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
padding: var(--ha-space-1) 0;
}
`,
];
}

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 { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
@customElement("ha-dialog-header")
export class HaDialogHeader extends LitElement {
@property({ type: String, attribute: "subtitle-position" })
public subtitlePosition: "above" | "below" = "below";
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`
<header class="header">
<div class="header-bar">
@@ -22,9 +11,12 @@ export class HaDialogHeader extends LitElement {
<slot name="navigationIcon"></slot>
</section>
<section class="header-content">
${this.subtitlePosition === "above"
? html`${subtitleSlot}${titleSlot}`
: html`${titleSlot}${subtitleSlot}`}
<div class="header-title">
<slot name="title"></slot>
</div>
<div class="header-subtitle">
<slot name="subtitle"></slot>
</div>
</section>
<section class="header-action-items">
<slot name="actionItems"></slot>
@@ -40,7 +32,6 @@ export class HaDialogHeader extends LitElement {
css`
:host {
display: block;
min-height: 48px;
}
:host([show-border]) {
border-bottom: 1px solid
@@ -49,7 +40,7 @@ export class HaDialogHeader extends LitElement {
.header-bar {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
padding: 4px;
box-sizing: border-box;
}
@@ -62,17 +53,13 @@ export class HaDialogHeader extends LitElement {
white-space: nowrap;
}
.header-title {
height: var(
--ha-dialog-header-title-height,
calc(var(--ha-font-size-xl) + 4px)
);
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-medium);
font-weight: var(--ha-font-weight-normal);
}
.header-subtitle {
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
line-height: 20px;
color: var(--secondary-text-color);
}
@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);
top: var(--dialog-surface-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);
border-radius: var(
--ha-dialog-border-radius,
@@ -133,13 +133,25 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
padding: var(--dialog-surface-padding);
}
:host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex;
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 {
display: flex;
align-items: center;

View File

@@ -49,7 +49,6 @@ export class HaExpansionPanel extends LitElement {
tabindex=${this.noCollapse ? -1 : 0}
aria-expanded=${this.expanded}
aria-controls="sect1"
part="summary"
>
${this.leftChevron ? chevronIcon : nothing}
<slot name="leading-icon"></slot>
@@ -171,11 +170,6 @@ export class HaExpansionPanel extends LitElement {
margin-left: 8px;
margin-inline-start: 8px;
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,

View File

@@ -61,7 +61,6 @@ export class HaFormString extends LitElement implements HaFormElement {
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autofocus=${this.schema.autofocus}
.autocomplete=${this.schema.autocomplete}
.suffix=${this.isPassword
? // 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 {
return html`
<div class="root" part="root">

View File

@@ -79,7 +79,6 @@ export class HaGenericPicker extends LitElement {
${!this._opened
? html`
<ha-picker-field
id="picker"
type="button"
compact
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 memoizeOne from "memoize-one";
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 {
createLabelRegistryEntry,
getLabels,
subscribeLabelRegistry,
} from "../data/label_registry";
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 = () => {
if (!this._labels || this._labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
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.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(
this.hass,
private _getItems = () =>
this._getLabels(
this._labels,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -154,7 +339,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
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"),
duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"),

View File

@@ -94,9 +94,6 @@ export class HaServiceControl extends LitElement {
@property({ attribute: "hide-picker", type: Boolean, reflect: true })
public hidePicker = false;
@property({ attribute: "hide-description", type: Boolean })
public hideDescription = false;
@state() private _value!: this["value"];
@state() private _checkedKeys = new Set();
@@ -472,136 +469,135 @@ export class HaServiceControl extends LitElement {
serviceData?.description;
return html`${this.hidePicker
? nothing
: html`<ha-service-picker
.hass=${this.hass}
.value=${this._value?.action}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
.showServiceId=${this.showServiceId}
></ha-service-picker>`}
${this.hideDescription
? nothing
: html`
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_secondary"
)}</span
><ha-selector
? nothing
: html`<ha-service-picker
.hass=${this.hass}
.selector=${this._targetSelector(
serviceData.target as TargetSelector,
this._value?.target
)}
.value=${this._value?.action}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.entity_id.description`
) || entityId.description}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.action_data"
)}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this._value?.data}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: serviceData?.fields.map((dataField) => {
if (!dataField.fields) {
return this._renderField(
dataField,
hasOptional,
domain,
serviceName,
targetEntities
);
}
@value-changed=${this._serviceChanged}
.showServiceId=${this.showServiceId}
></ha-service-picker>`}
const fields = Object.entries(dataField.fields).map(
([key, field]) => ({ key, ...field })
);
return fields.length &&
this._hasFilteredFields(fields, targetEntities)
? html`<ha-expansion-panel
left-chevron
.expanded=${!dataField.collapsed}
.header=${this.hass.localize(
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`
) ||
dataField.name ||
dataField.key}
.secondary=${this._getSectionDescription(
dataField,
domain,
serviceName
)}
>
<ha-service-section-icon
slot="icons"
.hass=${this.hass}
.service=${this._value!.action}
.section=${dataField.key}
></ha-service-section-icon>
${Object.entries(dataField.fields).map(([key, field]) =>
this._renderField(
{ key, ...field },
hasOptional,
domain,
serviceName,
targetEntities
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
)}
</ha-expansion-panel>`
: nothing;
})} `;
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_secondary"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(
serviceData.target as TargetSelector,
this._value?.target
)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.entity_id.description`
) || entityId.description}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.action_data"
)}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this._value?.data}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: serviceData?.fields.map((dataField) => {
if (!dataField.fields) {
return this._renderField(
dataField,
hasOptional,
domain,
serviceName,
targetEntities
);
}
const fields = Object.entries(dataField.fields).map(
([key, field]) => ({ key, ...field })
);
return fields.length &&
this._hasFilteredFields(fields, targetEntities)
? html`<ha-expansion-panel
left-chevron
.expanded=${!dataField.collapsed}
.header=${this.hass.localize(
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`
) ||
dataField.name ||
dataField.key}
.secondary=${this._getSectionDescription(
dataField,
domain,
serviceName
)}
>
<ha-service-section-icon
slot="icons"
.hass=${this.hass}
.service=${this._value!.action}
.section=${dataField.key}
></ha-service-section-icon>
${Object.entries(dataField.fields).map(([key, field]) =>
this._renderField(
{ key, ...field },
hasOptional,
domain,
serviceName,
targetEntities
)
)}
</ha-expansion-panel>`
: nothing;
})} `;
}
private _getSectionDescription(

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ export class HaTooltip extends Tooltip {
css`
:host {
--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(
--ha-tooltip-font-family,
var(--ha-font-family-body)

View File

@@ -0,0 +1,97 @@
import {
mdiAvTimer,
mdiCalendar,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiHomeAssistant,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
state: mdiStateMachine,
geo_location: mdiMapMarker,
homeassistant: mdiHomeAssistant,
mqtt: mdiSwapHorizontal,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
conversation: mdiMicrophoneMessage,
tag: mdiNfcVariant,
template: mdiCodeBraces,
time: mdiClockOutline,
time_pattern: mdiAvTimer,
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
@customElement("ha-trigger-icon")
export class HaTriggerIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public trigger?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.trigger) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.trigger!);
return html`
<ha-svg-icon
.path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-trigger-icon": HaTriggerIcon;
}
}

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,6 +1,7 @@
import type {
HassEntityAttributeBase,
HassEntityBase,
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { navigate } from "../common/navigate";
@@ -11,6 +12,7 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script";
import { migrateAutomationAction } from "./script";
import type { TriggerDescription } from "./trigger";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
@@ -84,6 +86,11 @@ export interface BaseTrigger {
id?: string;
variables?: Record<string, unknown>;
enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformTrigger extends BaseTrigger {
target?: HassServiceTarget;
}
export interface StateTrigger extends BaseTrigger {
@@ -570,6 +577,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Trigger | Trigger[]) => boolean;
toggleYamlMode: () => void;
config: Trigger;
description?: TriggerDescription;
yamlMode: boolean;
uiSupported: boolean;
}

View File

@@ -803,9 +803,15 @@ const tryDescribeTrigger = (
);
}
const triggerType = trigger.trigger;
const [domain, type] = triggerType.split(".", 2);
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
`component.${domain}.triggers.${type || "_"}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${triggerType}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);

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 { getDeviceContext } from "../common/entity/context/get_device_context";
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 { ConfigEntry } from "./config_entries";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import type { EntitySources } from "./entity_sources";
import { domainToName } from "./integration";
import type { RegistryEntry } from "./registry";
export {
@@ -171,147 +163,3 @@ export const getDeviceIntegrationLookup = (
}
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";
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 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 type { Store } from "home-assistant-js-websocket/dist/store";
import memoizeOne from "memoize-one";
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 { caseInsensitiveStringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { debounce } from "../common/util/debounce";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import { domainToName } from "./integration";
import type { LightColor } from "./light";
import type { RegistryEntry } from "./registry";
@@ -329,122 +324,3 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
});
export interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export const getEntities = (
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
): EntityComboBoxItem[] => {
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

@@ -131,14 +131,19 @@ const resources: {
all?: Promise<Record<string, ServiceIcons>>;
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
};
triggers: {
all?: Promise<Record<string, TriggerIcons>>;
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
};
} = {
entity: {},
entity_component: {},
services: { domains: {} },
triggers: { domains: {} },
};
interface IconResources<
T extends ComponentIcons | PlatformIcons | ServiceIcons,
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons,
> {
resources: Record<string, T>;
}
@@ -182,12 +187,22 @@ type ServiceIcons = Record<
{ service: string; sections?: Record<string, string> }
>;
export type IconCategory = "entity" | "entity_component" | "services";
type TriggerIcons = Record<
string,
{ trigger: string; sections?: Record<string, string> }
>;
export type IconCategory =
| "entity"
| "entity_component"
| "services"
| "triggers";
interface CategoryType {
entity: PlatformIcons;
entity_component: ComponentIcons;
services: ServiceIcons;
triggers: TriggerIcons;
}
export const getHassIcons = async <T extends IconCategory>(
@@ -265,12 +280,10 @@ export const getServiceIcons = async (
if (!force && resources.services.all) {
return resources.services.all;
}
resources.services.all = getHassIcons(hass, "services", domain).then(
(res) => {
resources.services.domains = res.resources;
return res?.resources;
}
);
resources.services.all = getHassIcons(hass, "services").then((res) => {
resources.services.domains = res.resources;
return res?.resources;
});
return resources.services.all;
}
if (!force && domain in resources.services.domains) {
@@ -292,6 +305,40 @@ export const getServiceIcons = async (
return resources.services.domains[domain];
};
export const getTriggerIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> => {
if (!domain) {
if (!force && resources.triggers.all) {
return resources.triggers.all;
}
resources.triggers.all = getHassIcons(hass, "triggers").then((res) => {
resources.triggers.domains = res.resources;
return res?.resources;
});
return resources.triggers.all;
}
if (!force && domain in resources.triggers.domains) {
return resources.triggers.domains[domain];
}
if (resources.triggers.all && !force) {
await resources.triggers.all;
if (domain in resources.triggers.domains) {
return resources.triggers.domains[domain];
}
}
if (!isComponentLoaded(hass, domain)) {
return undefined;
}
const result = getHassIcons(hass, "triggers", domain);
resources.triggers.domains[domain] = result.then(
(res) => res?.resources[domain]
);
return resources.triggers.domains[domain];
};
// Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -471,6 +518,26 @@ export const attributeIcon = async (
return icon;
};
export const triggerIcon = async (
hass: HomeAssistant,
trigger: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = trigger.includes(".") ? computeDomain(trigger) : trigger;
const triggerName = trigger.includes(".") ? computeObjectId(trigger) : "_";
const triggerIcons = await getTriggerIcons(hass, domain);
if (triggerIcons) {
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
icon = trgrIcon?.trigger;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const serviceIcon = async (
hass: HomeAssistant,
service: string

View File

@@ -1,20 +1,9 @@
import { mdiLabel } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
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 {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import type { RegistryEntry } from "./registry";
export interface LabelRegistryEntry extends RegistryEntry {
@@ -99,178 +88,3 @@ export const deleteLabelRegistryEntry = (
type: "config/label_registry/delete",
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 { isTTSMediaSource } from "./tts";
import { generateEntityFilter } from "../common/entity/entity_filter";
interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
media_content_id?: string;
media_content_type?: string;
@@ -522,3 +524,33 @@ export const mediaPlayerJoin = (
export const mediaPlayerUnjoin = (hass: HomeAssistant, entity_id: string) =>
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

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

View File

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

@@ -73,7 +73,8 @@ export type TranslationCategory =
| "application_credentials"
| "issues"
| "selector"
| "services";
| "services"
| "triggers";
export const subscribeTranslationPreferences = (
hass: HomeAssistant,

View File

@@ -1,53 +1,12 @@
import {
mdiAvTimer,
mdiCalendar,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiMapClock,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { mdiDotsHorizontal, mdiMapClock, mdiShape } from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type { HomeAssistant } from "../types";
import type {
AutomationElementGroup,
Trigger,
TriggerList,
} from "./automation";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
state: mdiStateMachine,
geo_location: mdiMapMarker,
homeassistant: mdiHomeAssistant,
mqtt: mdiSwapHorizontal,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
conversation: mdiMicrophoneMessage,
tag: mdiNfcVariant,
template: mdiCodeBraces,
time: mdiClockOutline,
time_pattern: mdiAvTimer,
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
import type { Selector, TargetSelector } from "./selector";
export const TRIGGER_GROUPS: AutomationElementGroup = {
device: {},
@@ -74,3 +33,26 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger;
export interface TriggerDescription {
target?: TargetSelector["target"];
fields: Record<
string,
{
example?: string | boolean | number;
default?: unknown;
required?: boolean;
selector?: Selector;
}
>;
}
export type TriggerDescriptions = Record<string, TriggerDescription>;
export const subscribeTriggers = (
hass: HomeAssistant,
callback: (triggers: TriggerDescriptions) => void
) =>
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
type: "trigger_platforms/subscribe",
});

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ import "../../../components/ha-tab-group";
import "../../../components/ha-tab-group-tab";
import "../../../components/ha-tooltip";
import type {
ForecastAttribute,
ForecastEvent,
ModernForecastType,
WeatherEntity,
@@ -132,24 +131,6 @@ class MoreInfoWeather extends LitElement {
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() {
if (!this.hass || !this.stateObj) {
return nothing;
@@ -333,90 +314,78 @@ class MoreInfoWeather extends LitElement {
: nothing}
<div class="forecast">
${forecast?.length
? this._groupForecastByDay(forecast).map((dayForecast) => {
const showDayHeader = hourly || dayNight;
return html`
<div class="forecast-day">
${showDayHeader
? html`<div class="forecast-day-header">
${formatDateWeekdayShort(
new Date(dayForecast[0].datetime),
this.hass!.locale,
this.hass!.config
)}
</div>`
: nothing}
<div class="forecast-day-content">
${dayForecast.map((item) =>
this._showValue(item.templow) ||
this._showValue(item.temperature)
? forecast.map((item) =>
this._showValue(item.templow) || this._showValue(item.temperature)
? html`
<div>
<div>
${dayNight
? html`
${formatDateWeekdayShort(
new Date(item.datetime),
this.hass!.locale,
this.hass!.config
)}
<div class="daynight">
${item.is_daytime !== false
? this.hass!.localize("ui.card.weather.day")
: this.hass!.localize(
"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`
<div class="forecast-item">
<div
class="forecast-item-label ${showDayHeader
? ""
: "no-header"}"
>
${hourly
? 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 class="forecast-image-icon">
${getWeatherStateIcon(
item.condition!,
this,
!(
item.is_daytime ||
item.is_daytime === undefined
)
)}
</div>
`
: nothing
)}
</div>
</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
)}°`
: hourly
? nothing
: "—"}
</div>
</div>
`
: nothing
)
: html`<ha-spinner size="medium"></ha-spinner>`}
</div>
@@ -587,46 +556,14 @@ class MoreInfoWeather extends LitElement {
user-select: none;
}
.forecast-day {
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;
.forecast > div {
text-align: center;
font-weight: var(--ha-font-weight-semi-bold);
}
.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);
padding: 0 10px;
}
.forecast .icon,
.forecast .temp {
margin: var(--ha-space-1) 0;
margin: 4px 0;
}
.forecast .temp {

View File

@@ -23,14 +23,8 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityEntryName,
computeEntityName,
} from "../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { computeEntityEntryName } from "../../common/entity/compute_entity_name";
import { getEntityEntryContext } from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate";
import "../../components/ha-button-menu";
@@ -327,34 +321,28 @@ export class MoreInfoDialog extends LitElement {
(isDefaultView && this._parentEntityIds.length === 0) ||
isSpecificInitialView;
const context = stateObj
? getEntityContext(
stateObj,
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;
let entityName: string | undefined;
let deviceName: string | undefined;
let areaName: string | undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
if (stateObj) {
entityName = this.hass.formatEntityName(stateObj, "entity");
deviceName = this.hass.formatEntityName(stateObj, "device");
areaName = this.hass.formatEntityName(stateObj, "area");
} else if (this._entry) {
const { device, area } = getEntityEntryContext(
this._entry,
this.hass.entities,
this.hass.devices,
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(
(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 { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { entityUseDeviceName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
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 { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-label";
import "../../components/ha-button";
import "../../components/ha-list";
import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
@@ -632,29 +631,14 @@ export class QuickBar extends LitElement {
const stateObj = this.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 useDeviceName = entityUseDeviceName(
stateObj,
this.hass.entities,
this.hass.devices
);
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 primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const translatedDomain = domainToName(
this.hass.localize,

View File

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

View File

@@ -0,0 +1,60 @@
import type { LitElement } from "lit";
import type { Constructor } from "../types";
export const UndoRedoMixin = <T extends Constructor<LitElement>, ConfigType>(
superClass: T
) => {
class UndoRedoClass extends superClass {
private _undoStack: ConfigType[] = [];
private _redoStack: ConfigType[] = [];
protected _undoStackLimit = 75;
protected pushToUndo(config: ConfigType) {
if (this._undoStack.length >= this._undoStackLimit) {
this._undoStack.shift();
}
this._undoStack.push({ ...config });
this._redoStack = [];
}
public undo() {
const currentConfig = this.currentConfig;
if (this._undoStack.length === 0 || !currentConfig) {
return;
}
this._redoStack.push({ ...currentConfig });
const config = this._undoStack.pop()!;
this.applyUndoRedo(config);
}
public redo() {
const currentConfig = this.currentConfig;
if (this._redoStack.length === 0 || !currentConfig) {
return;
}
this._undoStack.push({ ...currentConfig });
const config = this._redoStack.pop()!;
this.applyUndoRedo(config);
}
public get canUndo(): boolean {
return this._undoStack.length > 0;
}
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
protected get currentConfig(): ConfigType | undefined {
return undefined;
}
protected applyUndoRedo(_: ConfigType) {
throw new Error("applyUndoRedo not implemented");
}
}
return UndoRedoClass;
};

View File

@@ -70,6 +70,7 @@ import { describeAction } from "../../../../data/script_i18n";
import { callExecuteScript } from "../../../../data/service";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
@@ -649,19 +650,21 @@ export default class HaAutomationActionRow extends LitElement {
};
private _onDelete = () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(window, "undo-change");
},
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete_confirm_text"
),
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
};

View File

@@ -44,7 +44,7 @@ import {
domainToName,
fetchIntegrationManifests,
} from "../../../data/integration";
import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger";
import { TRIGGER_GROUPS } from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { HaFuse } from "../../../resources/fuse";
@@ -54,6 +54,7 @@ import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
import { TRIGGER_ICONS } from "../../../components/ha-trigger-icon";
const TYPES = {
trigger: { groups: TRIGGER_GROUPS, icons: TRIGGER_ICONS },

View File

@@ -53,6 +53,7 @@ import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
@@ -531,19 +532,21 @@ export default class HaAutomationConditionRow extends LitElement {
};
private _onDelete = () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(window, "undo-change");
},
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.delete_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.automation.editor.conditions.delete_confirm_text"
),
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
};

View File

@@ -74,7 +74,7 @@ import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info
import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { UndoRedoMixin } from "../../../mixins/undo-redo-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types";
import { isMac } from "../../../util/is_mac";
@@ -111,9 +111,12 @@ declare global {
}
}
export class HaAutomationEditor extends PreventUnsavedMixin(
KeyboardShortcutMixin(LitElement)
) {
const baseEditorMixins = PreventUnsavedMixin(KeyboardShortcutMixin(LitElement));
export class HaAutomationEditor extends UndoRedoMixin<
typeof baseEditorMixins,
AutomationConfig
>(baseEditorMixins) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public automationId: string | null = null;
@@ -180,11 +183,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void;
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this._config!,
});
protected willUpdate(changedProps) {
super.willUpdate(changedProps);
@@ -237,8 +235,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.undo")}
.path=${mdiUndo}
@click=${this._undo}
.disabled=${!this._undoRedoController.canUndo}
@click=${this.undo}
.disabled=${!this.canUndo}
id="button-undo"
>
</ha-icon-button>
@@ -255,8 +253,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.redo")}
.path=${mdiRedo}
@click=${this._redo}
.disabled=${!this._undoRedoController.canRedo}
@click=${this.redo}
.disabled=${!this.canRedo}
id="button-redo"
>
</ha-icon-button>
@@ -300,16 +298,16 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
${this._mode === "gui" && this.narrow
? html`<ha-list-item
graphic="icon"
@click=${this._undo}
.disabled=${!this._undoRedoController.canUndo}
@click=${this.undo}
.disabled=${!this.canUndo}
>
${this.hass.localize("ui.common.undo")}
<ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
@click=${this._redo}
.disabled=${!this._undoRedoController.canRedo}
@click=${this.redo}
.disabled=${!this.canRedo}
>
${this.hass.localize("ui.common.redo")}
<ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon>
@@ -520,6 +518,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation}
@undo-paste=${this.undo}
>
<div class="alert-wrapper" slot="alerts">
${this._errors || stateObj?.state === UNAVAILABLE
@@ -792,7 +791,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
ev.stopPropagation();
if (this._config) {
this._undoRedoController.commit(this._config);
this.pushToUndo(this._config);
}
this._config = ev.detail.value;
@@ -1203,9 +1202,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
x: () => this._cutSelectedRow(),
Delete: () => this._deleteSelectedRow(),
Backspace: () => this._deleteSelectedRow(),
z: () => this._undo(),
Z: () => this._redo(),
y: () => this._redo(),
z: () => this.undo(),
Z: () => this.redo(),
y: () => this.redo(),
};
}
@@ -1239,20 +1238,16 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
this._manualEditor?.deleteSelectedRow();
}
private _applyUndoRedo(config: AutomationConfig) {
protected get currentConfig() {
return this._config;
}
protected applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this._config = config;
this._dirty = true;
}
private _undo() {
this._undoRedoController.undo();
}
private _redo() {
this._undoRedoController.redo();
}
static get styles(): CSSResultGroup {
return [
haStyle,

View File

@@ -616,7 +616,7 @@ export class HaManualAutomationEditor extends LitElement {
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(this, "undo-change");
fireEvent(this, "undo-paste");
this._pastedConfig = undefined;
},
@@ -742,5 +742,6 @@ declare global {
"open-sidebar": SidebarConfig;
"request-close-sidebar": undefined;
"close-sidebar": undefined;
"undo-paste": undefined;
}
}

View File

@@ -33,7 +33,10 @@ import { describeCondition } from "../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
import type { Action, Option } from "../../../../data/script";
import { showPromptDialog } from "../../../../dialogs/generic/show-dialog-box";
import {
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import "../action/ha-automation-action";
@@ -46,7 +49,6 @@ import {
overflowStyles,
rowStyles,
} from "../styles";
import { showToast } from "../../../../util/toast";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -363,21 +365,23 @@ export default class HaAutomationOptionRow extends LitElement {
private _removeOption = () => {
if (this.option) {
fireEvent(this, "value-changed", {
value: null,
});
if (this._selected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(window, "undo-change");
},
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.delete_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete_confirm_text"
),
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", {
value: null,
});
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
}

View File

@@ -27,7 +27,6 @@ import type HaAutomationConditionEditor from "../action/ha-automation-action-edi
import { getAutomationActionType } from "../action/ha-automation-action-row";
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "../trigger/ha-automation-trigger-editor";
import "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-action")

View File

@@ -17,7 +17,6 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";

View File

@@ -22,6 +22,8 @@ import { overflowStyles, sidebarEditorStyles } from "../styles";
import "../trigger/ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "../trigger/ha-automation-trigger-editor";
import "./ha-automation-sidebar-card";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../common/entity/compute_object_id";
@customElement("ha-automation-sidebar-trigger")
export default class HaAutomationSidebarTrigger extends LitElement {
@@ -68,9 +70,22 @@ export default class HaAutomationSidebarTrigger extends LitElement {
"ui.panel.config.automation.editor.triggers.trigger"
);
const title = this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.${type}.label`
);
const domain =
"trigger" in this.config.config &&
this.config.config.trigger.includes(".")
? computeDomain(this.config.config.trigger)
: "trigger" in this.config.config && this.config.config.trigger;
const triggerName =
"trigger" in this.config.config &&
this.config.config.trigger.includes(".")
? computeObjectId(this.config.config.trigger)
: "_";
const title =
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.${type}.label`
) ||
this.hass.localize(`component.${domain}.triggers.${triggerName}.name`);
return html`
<ha-automation-sidebar-card
@@ -246,6 +261,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
class="sidebar-editor"
.hass=${this.hass}
.trigger=${this.config.config}
.description=${this.config.description}
@value-changed=${this._valueChangedSidebar}
@yaml-changed=${this._yamlChangedSidebar}
.uiSupported=${this.config.uiSupported}

View File

@@ -9,10 +9,12 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Trigger } from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
import "./types/ha-automation-trigger-platform";
@customElement("ha-automation-trigger-editor")
export default class HaAutomationTriggerEditor extends LitElement {
@@ -29,6 +31,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ attribute: false }) public description?: TriggerDescription;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
protected render() {
@@ -88,11 +92,18 @@ export default class HaAutomationTriggerEditor extends LitElement {
`
: nothing}
<div @value-changed=${this._onUiChanged}>
${dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
})}
${this.description
? html`<ha-automation-trigger-platform
.hass=${this.hass}
.trigger=${this.trigger}
.description=${this.description}
.disabled=${this.disabled}
></ha-automation-trigger-platform>`
: dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
})}
</div>
`}
</div>

View File

@@ -40,9 +40,11 @@ import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-svg-icon";
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
import type {
AutomationClipboard,
Trigger,
TriggerList,
TriggerSidebarConfig,
} from "../../../../data/automation";
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
@@ -50,9 +52,11 @@ import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
import { TRIGGER_ICONS, isTriggerList } from "../../../../data/trigger";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
@@ -72,6 +76,7 @@ import "./types/ha-automation-trigger-list";
import "./types/ha-automation-trigger-mqtt";
import "./types/ha-automation-trigger-numeric_state";
import "./types/ha-automation-trigger-persistent_notification";
import "./types/ha-automation-trigger-platform";
import "./types/ha-automation-trigger-state";
import "./types/ha-automation-trigger-sun";
import "./types/ha-automation-trigger-tag";
@@ -137,6 +142,9 @@ export default class HaAutomationTriggerRow extends LitElement {
@state() private _warnings?: string[];
@property({ attribute: false })
public triggerDescriptions: TriggerDescriptions = {};
@property({ type: Boolean }) public narrow = false;
@query("ha-automation-trigger-editor")
@@ -178,18 +186,24 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _renderRow() {
const type = this._getType(this.trigger);
const type = this._getType(this.trigger, this.triggerDescriptions);
const supported = this._uiSupported(type);
const yamlMode = this._yamlMode || !supported;
return html`
<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>
${type === "list"
? html`<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>`
: html`<ha-trigger-icon
slot="leading-icon"
.hass=${this.hass}
.trigger=${(this.trigger as Exclude<Trigger, TriggerList>).trigger}
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
</h3>
@@ -393,6 +407,9 @@ export default class HaAutomationTriggerRow extends LitElement {
<ha-automation-trigger-editor
.hass=${this.hass}
.trigger=${this.trigger}
.description=${"trigger" in this.trigger
? this.triggerDescriptions[this.trigger.trigger]
: undefined}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
.uiSupported=${supported}
@@ -552,6 +569,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
public openSidebar(trigger?: Trigger): void {
trigger = trigger || this.trigger;
fireEvent(this, "open-sidebar", {
save: (value) => {
fireEvent(this, "value-changed", { value });
@@ -576,8 +594,14 @@ export default class HaAutomationTriggerRow extends LitElement {
duplicate: this._duplicateTrigger,
cut: this._cutTrigger,
insertAfter: this._insertAfter,
config: trigger || this.trigger,
uiSupported: this._uiSupported(this._getType(trigger || this.trigger)),
config: trigger,
uiSupported: this._uiSupported(
this._getType(trigger, this.triggerDescriptions)
),
description:
"trigger" in trigger
? this.triggerDescriptions[trigger.trigger]
: undefined,
yamlMode: this._yamlMode,
} satisfies TriggerSidebarConfig);
this._selected = true;
@@ -602,20 +626,22 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _onDelete = () => {
fireEvent(this, "value-changed", { value: null });
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.triggers.delete_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.automation.editor.triggers.delete_confirm_text"
),
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(window, "undo-change");
},
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
};
@@ -759,8 +785,18 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _getType = memoizeOne((trigger: Trigger) =>
isTriggerList(trigger) ? "list" : trigger.trigger
private _getType = memoizeOne(
(trigger: Trigger, triggerDescriptions: TriggerDescriptions) => {
if (isTriggerList(trigger)) {
return "list";
}
if (trigger.trigger in triggerDescriptions) {
return "platform";
}
return trigger.trigger;
}
);
private _uiSupported = memoizeOne(

View File

@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -17,7 +18,9 @@ import type {
Trigger,
TriggerList,
} from "../../../../data/automation";
import { isTriggerList } from "../../../../data/trigger";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
@@ -26,10 +29,9 @@ import {
import { automationRowsStyles } from "../styles";
import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
import { ensureArray } from "../../../../common/array/ensure-array";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends LitElement {
export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public triggers!: Trigger[];
@@ -62,6 +64,23 @@ export default class HaAutomationTrigger extends LitElement {
private _triggerKeys = new WeakMap<Trigger, string>();
@state() private _triggerDescriptions: TriggerDescriptions = {};
protected hassSubscribe() {
return [
subscribeTriggers(this.hass, (triggers) => this._addTriggers(triggers)),
];
}
private _addTriggers(triggers: TriggerDescriptions) {
this._triggerDescriptions = { ...this._triggerDescriptions, ...triggers };
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
}
protected render() {
return html`
<ha-sortable
@@ -85,6 +104,7 @@ export default class HaAutomationTrigger extends LitElement {
.first=${idx === 0}
.last=${idx === this.triggers.length - 1}
.trigger=${trg}
.triggerDescriptions=${this._triggerDescriptions}
@duplicate=${this._duplicateTrigger}
@insert-after=${this._insertAfter}
@move-down=${this._moveDown}

View File

@@ -0,0 +1,406 @@
import { mdiHelpCircle } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../../common/entity/compute_object_id";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-textfield";
import "../../../../../components/ha-yaml-editor";
import "../../../../../components/user/ha-users-picker";
import type { PlatformTrigger } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
import type { TriggerDescription } from "../../../../../data/trigger";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
const showOptionalToggle = (field) =>
field.selector &&
!field.required &&
!("boolean" in field.selector && field.default);
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: PlatformTrigger;
@property({ attribute: false }) public description?: TriggerDescription;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = true;
@state() private _checkedKeys = new Set();
@state() private _manifest?: IntegrationManifest;
public static get defaultConfig(): PlatformTrigger {
return { trigger: "" };
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (!this.hasUpdated) {
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("selector");
}
if (!changedProperties.has("trigger")) {
return;
}
const oldValue = changedProperties.get("trigger") as
| undefined
| this["trigger"];
// Fetch the manifest if we have a trigger selected and the trigger domain changed.
// If no trigger is selected, clear the manifest.
if (this.trigger?.trigger) {
const domain = this.trigger.trigger.includes(".")
? computeDomain(this.trigger.trigger)
: this.trigger.trigger;
const oldDomain = oldValue?.trigger.includes(".")
? computeDomain(oldValue.trigger)
: oldValue?.trigger;
if (domain !== oldDomain) {
this._fetchManifest(domain);
}
} else {
this._manifest = undefined;
}
}
protected render() {
const domain = this.trigger.trigger.includes(".")
? computeDomain(this.trigger.trigger)
: this.trigger.trigger;
const triggerName = this.trigger.trigger.includes(".")
? computeObjectId(this.trigger.trigger)
: "_";
const description = this.hass.localize(
`component.${domain}.triggers.${triggerName}.description`
);
const triggerDesc = this.description;
const shouldRenderDataYaml = !triggerDesc?.fields;
const hasOptional = Boolean(
triggerDesc?.fields &&
Object.values(triggerDesc.fields).some((field) =>
showOptionalToggle(field)
)
);
return html`
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
${triggerDesc && "target" in triggerDesc
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_secondary"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector
></ha-settings-row>`
: nothing}
${shouldRenderDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.action_data"
)}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this.trigger?.options}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: Object.entries(triggerDesc.fields).map(([fieldName, dataField]) =>
this._renderField(
fieldName,
dataField,
hasOptional,
domain,
triggerName
)
)}
`;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
);
private _renderField = (
fieldName: string,
dataField: TriggerDescription["fields"][string],
hasOptional: boolean,
domain: string | undefined,
triggerName: string | undefined
) => {
const selector = dataField?.selector ?? { text: null };
const showOptional = showOptionalToggle(dataField);
return dataField.selector
? html`<ha-settings-row .narrow=${this.narrow}>
${!showOptional
? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""
: html`<ha-checkbox
.key=${fieldName}
.checked=${this._checkedKeys.has(fieldName) ||
(this.trigger?.options &&
this.trigger.options[fieldName] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
>${this.hass.localize(
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.name`
) || triggerName}</span
>
<span slot="description"
>${this.hass.localize(
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.description`
)}</span
>
<ha-selector
.disabled=${this.disabled ||
(showOptional &&
!this._checkedKeys.has(fieldName) &&
(!this.trigger?.options ||
this.trigger.options[fieldName] === undefined))}
.hass=${this.hass}
.selector=${selector}
.key=${fieldName}
@value-changed=${this._dataChanged}
.value=${this.trigger?.options
? this.trigger.options[fieldName]
: undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
></ha-selector>
</ha-settings-row>`
: "";
};
private _dataChanged(ev: CustomEvent) {
ev.stopPropagation();
if (ev.detail.isValid === false) {
// Don't clear an object selector that returns invalid YAML
return;
}
const key = (ev.currentTarget as any).key;
const value = ev.detail.value;
if (
this.trigger?.options?.[key] === value ||
((!this.trigger?.options || !(key in this.trigger.options)) &&
(value === "" || value === undefined))
) {
return;
}
const options = { ...this.trigger?.options, [key]: value };
if (
value === "" ||
value === undefined ||
(typeof value === "object" && !Object.keys(value).length)
) {
delete options[key];
}
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options,
},
});
}
private _targetChanged(ev: CustomEvent): void {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
...this.trigger,
target: ev.detail.value,
},
});
}
private _checkboxChanged(ev) {
const checked = ev.currentTarget.checked;
const key = ev.currentTarget.key;
let options;
if (checked) {
this._checkedKeys.add(key);
const field =
this.description &&
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
options = {
...this.trigger?.options,
[key]: defaultValue,
};
}
} else {
this._checkedKeys.delete(key);
options = { ...this.trigger?.options };
delete options[key];
}
if (options) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options,
},
});
}
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
if (!this.trigger?.trigger) {
return "";
}
return this.hass.localize(
`component.${computeDomain(this.trigger.trigger)}.selector.${key}`
);
};
private async _fetchManifest(integration: string) {
this._manifest = undefined;
try {
this._manifest = await fetchIntegrationManifest(this.hass, integration);
} catch (_err: any) {
// Ignore if loading manifest fails. Probably bad JSON in manifest
}
}
static styles = css`
ha-settings-row {
padding: var(--service-control-padding, 0 16px);
}
ha-settings-row[narrow] {
padding-bottom: 8px;
}
ha-settings-row {
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-service-picker,
ha-entity-picker,
ha-yaml-editor {
display: block;
margin: var(--service-control-padding, 0 16px);
}
ha-yaml-editor {
padding: 16px 0;
}
p {
margin: var(--service-control-padding, 0 16px);
padding: 16px 0;
}
:host([hide-picker]) p {
padding-top: 0;
}
.checkbox-spacer {
width: 32px;
}
ha-checkbox {
margin-left: -16px;
margin-inline-start: -16px;
margin-inline-end: initial;
}
.help-icon {
color: var(--secondary-text-color);
}
.description {
justify-content: space-between;
display: flex;
align-items: center;
padding-right: 2px;
padding-inline-end: 2px;
padding-inline-start: initial;
}
.description p {
direction: ltr;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-trigger-platform": HaPlatformTrigger;
}
}

View File

@@ -71,9 +71,6 @@ import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup"
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
import { downloadBackup } from "./helper/download_backup";
import type { HaMdMenu } from "../../../components/ha-md-menu";
import "../../../components/ha-md-menu";
import "../../../components/ha-md-menu-item";
interface BackupRow extends DataTableRowData, BackupContent {
formatted_type: string;
@@ -123,10 +120,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
@query("#overflow-menu") private _overflowMenu?: HaMdMenu;
private _overflowBackup?: BackupContent;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
@@ -261,12 +254,24 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
hideable: false,
type: "overflow-menu",
template: (backup) => html`
<ha-icon-button
.selected=${backup}
.label=${this.hass.localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}
@click=${this._toggleOverflowMenu}
></ha-icon-button>
<ha-icon-overflow-menu
.hass=${this.hass}
narrow
.items=${[
{
label: this.hass.localize("ui.common.download"),
path: mdiDownload,
action: () => this._downloadBackup(backup),
},
{
label: this.hass.localize("ui.common.delete"),
path: mdiDelete,
action: () => this._deleteBackup(backup),
warning: true,
},
]}
>
</ha-icon-overflow-menu>
`,
},
})
@@ -285,20 +290,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
: undefined
);
private _toggleOverflowMenu = (ev) => {
if (!this._overflowMenu) {
return;
}
if (this._overflowMenu.open) {
this._overflowMenu.close();
return;
}
this._overflowBackup = ev.target.selected;
this._overflowMenu.anchorElement = ev.target;
this._overflowMenu.show();
};
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
@@ -375,16 +366,14 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
clickable
id="backup_id"
has-filters
.filters=${
Object.values(this._filters).filter((filter) =>
Array.isArray(filter)
? filter.length
: filter &&
Object.values(filter).some((val) =>
Array.isArray(val) ? val.length : val
)
).length
}
.filters=${Object.values(this._filters).filter((filter) =>
Array.isArray(filter)
? filter.length
: filter &&
Object.values(filter).some((val) =>
Array.isArray(val) ? val.length : val
)
).length}
selectable
.selected=${this._selected.length}
.initialGroupColumn=${this._activeGrouping}
@@ -426,30 +415,28 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
</div>
<div slot="selection-bar">
${
!this.narrow
? html`
<ha-button
appearance="plain"
@click=${this._deleteSelected}
variant="danger"
>
${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
</ha-button>
`
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
.path=${mdiDelete}
class="warning"
@click=${this._deleteSelected}
></ha-icon-button>
`
}
${!this.narrow
? html`
<ha-button
appearance="plain"
@click=${this._deleteSelected}
variant="danger"
>
${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
</ha-button>
`
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.backup.backups.delete_selected"
)}
.path=${mdiDelete}
class="warning"
@click=${this._deleteSelected}
></ha-icon-button>
`}
</div>
<ha-filter-states
@@ -462,43 +449,29 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
expanded
.narrow=${this.narrow}
></ha-filter-states>
${
!this._needsOnboarding
? html`
<ha-fab
slot="fab"
?disabled=${backupInProgress}
.label=${this.hass.localize(
"ui.panel.config.backup.backups.new_backup"
)}
extended
@click=${this._newBackup}
>
${backupInProgress
? html`<div slot="icon" class="loading">
<ha-spinner .size=${"small"}></ha-spinner>
</div>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab>
`
: nothing
}
${!this._needsOnboarding
? html`
<ha-fab
slot="fab"
?disabled=${backupInProgress}
.label=${this.hass.localize(
"ui.panel.config.backup.backups.new_backup"
)}
extended
@click=${this._newBackup}
>
${backupInProgress
? html`<div slot="icon" class="loading">
<ha-spinner .size=${"small"}></ha-spinner>
</div>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab>
`
: nothing}
</hass-tabs-subpage-data-table>
<ha-md-menu id="overflow-menu" positioning="fixed">
<ha-md-menu-item .clickAction=${this._downloadBackup}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-md-menu-item>
<ha-md-menu-item class="warning" .clickAction=${this._deleteBackup}>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-md-menu-item>
</ha-md-menu>
>
</ha-icon-overflow-menu>
`;
}
@@ -572,18 +545,11 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
navigate(`/config/backup/details/${id}`);
}
private async _downloadBackup(): Promise<void> {
if (!this._overflowBackup) {
return;
}
downloadBackup(this.hass, this, this._overflowBackup, this.config);
private async _downloadBackup(backup: BackupContent): Promise<void> {
downloadBackup(this.hass, this, backup, this.config);
}
private async _deleteBackup(): Promise<void> {
if (!this._overflowBackup) {
return;
}
private async _deleteBackup(backup: BackupContent): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.backup.dialogs.delete.title"),
text: this.hass.localize("ui.panel.config.backup.dialogs.delete.text"),
@@ -596,11 +562,9 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
try {
await deleteBackup(this.hass, this._overflowBackup.backup_id);
if (this._selected.includes(this._overflowBackup.backup_id)) {
this._selected = this._selected.filter(
(id) => id !== this._overflowBackup!.backup_id
);
await deleteBackup(this.hass, backup.backup_id);
if (this._selected.includes(backup.backup_id)) {
this._selected = this._selected.filter((id) => id !== backup.backup_id);
}
} catch (err: any) {
showAlertDialog(this, {

View File

@@ -770,39 +770,43 @@ export class HaConfigDevicePage extends LitElement {
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
<div>
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes(
"warning"
)
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
</div>
${actions.length
? html`

View File

@@ -65,7 +65,7 @@ import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { UndoRedoMixin } from "../../../mixins/undo-redo-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types";
import { isMac } from "../../../util/is_mac";
@@ -78,9 +78,14 @@ import "./blueprint-script-editor";
import "./manual-script-editor";
import type { HaManualScriptEditor } from "./manual-script-editor";
export class HaScriptEditor extends SubscribeMixin(
const baseEditorMixins = SubscribeMixin(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
) {
);
export class HaScriptEditor extends UndoRedoMixin<
typeof baseEditorMixins,
ScriptConfig
>(baseEditorMixins) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public scriptId: string | null = null;
@@ -136,11 +141,6 @@ export class HaScriptEditor extends SubscribeMixin(
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void;
private _undoRedoController = new UndoRedoController<ScriptConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this._config!,
});
protected willUpdate(changedProps) {
super.willUpdate(changedProps);
@@ -188,8 +188,8 @@ export class HaScriptEditor extends SubscribeMixin(
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.undo")}
.path=${mdiUndo}
@click=${this._undo}
.disabled=${!this._undoRedoController.canUndo}
@click=${this.undo}
.disabled=${!this.canUndo}
id="button-undo"
>
</ha-icon-button>
@@ -205,8 +205,8 @@ export class HaScriptEditor extends SubscribeMixin(
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.redo")}
.path=${mdiRedo}
@click=${this._redo}
.disabled=${!this._undoRedoController.canRedo}
@click=${this.redo}
.disabled=${!this.canRedo}
id="button-redo"
>
</ha-icon-button>
@@ -249,16 +249,16 @@ export class HaScriptEditor extends SubscribeMixin(
${this._mode === "gui" && this.narrow
? html`<ha-list-item
graphic="icon"
@click=${this._undo}
.disabled=${!this._undoRedoController.canUndo}
@click=${this.undo}
.disabled=${!this.canUndo}
>
${this.hass.localize("ui.common.undo")}
<ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
@click=${this._redo}
.disabled=${!this._undoRedoController.canRedo}
@click=${this.redo}
.disabled=${!this.canRedo}
>
${this.hass.localize("ui.common.redo")}
<ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon>
@@ -463,6 +463,7 @@ export class HaScriptEditor extends SubscribeMixin(
@value-changed=${this._valueChanged}
@editor-save=${this._handleSaveScript}
@save-script=${this._handleSaveScript}
@undo-paste=${this.undo}
>
<div class="alert-wrapper" slot="alerts">
${this._errors || stateObj?.state === UNAVAILABLE
@@ -678,7 +679,7 @@ export class HaScriptEditor extends SubscribeMixin(
private _valueChanged(ev) {
if (this._config) {
this._undoRedoController.commit(this._config);
this.pushToUndo(this._config);
}
this._config = ev.detail.value;
@@ -775,7 +776,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
if (this._config) {
this._undoRedoController.commit(this._config);
this.pushToUndo(this._config);
}
this._manualEditor?.addFields();
@@ -1109,9 +1110,9 @@ export class HaScriptEditor extends SubscribeMixin(
x: () => this._cutSelectedRow(),
Delete: () => this._deleteSelectedRow(),
Backspace: () => this._deleteSelectedRow(),
z: () => this._undo(),
Z: () => this._redo(),
y: () => this._redo(),
z: () => this.undo(),
Z: () => this.redo(),
y: () => this.redo(),
};
}
@@ -1145,20 +1146,16 @@ export class HaScriptEditor extends SubscribeMixin(
this._manualEditor?.deleteSelectedRow();
}
private _applyUndoRedo(config: ScriptConfig) {
protected get currentConfig() {
return this._config;
}
protected applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar();
this._config = config;
this._dirty = true;
}
private _undo() {
this._undoRedoController.undo();
}
private _redo() {
this._undoRedoController.redo();
}
static get styles(): CSSResultGroup {
return [
haStyle,

View File

@@ -20,13 +20,13 @@ import "../../../components/ha-md-menu-item";
import type { ScriptFieldSidebarConfig } from "../../../data/automation";
import type { Field } from "../../../data/script";
import { SELECTOR_SELECTOR_BUILDING_BLOCKS } from "../../../data/selector/selector_selector";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { indentStyle, overflowStyles } from "../automation/styles";
import "./ha-script-field-selector-editor";
import type HaScriptFieldSelectorEditor from "./ha-script-field-selector-editor";
import { showToast } from "../../../util/toast";
@customElement("ha-script-field-row")
export default class HaScriptFieldRow extends LitElement {
@@ -386,19 +386,21 @@ export default class HaScriptFieldRow extends LitElement {
};
private _onDelete = () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected || this._selectorRowSelected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(window, "undo-change");
},
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.script.editor.field_delete_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.script.editor.field_delete_confirm_text"
),
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected || this._selectorRowSelected) {
fireEvent(this, "close-sidebar");
}
},
});
};

View File

@@ -491,7 +491,7 @@ export class HaManualScriptEditor extends LitElement {
action: {
text: this.hass.localize("ui.common.undo"),
action: () => {
fireEvent(this, "undo-change");
fireEvent(this, "undo-paste");
this._pastedConfig = undefined;
},

View File

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

View File

@@ -1,11 +1,9 @@
import { mdiRefresh } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -18,15 +16,17 @@ import "../../components/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import "../../components/ha-target-picker";
import { filterLogbookCompatibleEntities } from "../../data/logbook";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
import { storage } from "../../common/decorators/storage";
import { ensureArray } from "../../common/array/ensure-array";
import { resolveEntityIDs } from "../../data/selector";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker";
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@@ -108,7 +108,6 @@ export class HaPanelLogbook extends LitElement {
.value=${this._targetPickerValue}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
@@ -364,10 +363,6 @@ export class HaPanelLogbook extends LitElement {
max-width: 400px;
}
ha-target-picker {
flex: 1;
}
:host([narrow]) ha-entity-picker {
max-width: none;
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 {
css,
@@ -13,6 +13,10 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import {
computeActiveAreaMediaStates,
type MediaPlayerEntity,
} from "../../../data/media-player";
import { computeCssColor } from "../../../common/color/compute-color";
import { BINARY_STATE_ON } from "../../../common/const";
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 {
const states = this._computeActiveAlertStates();
private _computeActiveAreaMediaStates(): MediaPlayerEntity[] {
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;
}
// 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`
<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 {
const states = this._computeActiveAlertStates();
@@ -563,7 +595,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
<div class="content ${classMap(contentClasses)}">
<ha-tile-icon>
${displayType === "compact"
? this._renderAlertSensorBadge()
? this._renderCompactBadge()
: nothing}
${icon
? html`<ha-icon slot="icon" .icon=${icon}></ha-icon>`
@@ -741,6 +773,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
.alert-badge {
--tile-badge-background-color: var(--orange-color);
}
.media-badge {
--tile-badge-background-color: var(--light-blue-color);
}
.alerts {
position: absolute;
top: 0;

View File

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

View File

@@ -83,21 +83,6 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
private _footerElement?: LovelaceHeaderFooter;
connectedCallback(): void {
super.connectedCallback();
this.addEventListener("row-visibility-changed", (ev) =>
this._updateRowVisibility(ev)
);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener(
"row-visibility-changed",
this._updateRowVisibility
);
}
set hass(hass: HomeAssistant) {
this._hass = hass;
this.shadowRoot
@@ -266,9 +251,18 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
#states {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--entities-card-row-gap, var(--card-row-gap, 8px));
}
#states > * {
margin: 8px 0;
}
#states > *:first-child {
margin-top: 0;
}
#states > *:last-child {
margin-bottom: 0;
}
#states > div > * {
@@ -326,16 +320,8 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
element.hass = this._hass;
}
return html`<div ?hidden=${element.hidden}>${element}</div>`;
return html`<div>${element}</div>`;
}
private _updateRowVisibility = (ev) => {
if (ev.detail?.value === false) {
ev.detail?.row?.parentElement!.style.setProperty("display", "none");
} else {
ev.detail?.row?.parentElement!.style.setProperty("display", "");
}
};
}
declare global {

View File

@@ -1,5 +1,5 @@
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 { classMap } from "lit/directives/class-map";
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 { DOMAINS_TOGGLE } from "../../../common/const";
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 { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-card";
@@ -47,11 +47,6 @@ export const getEntityDefaultTileIconAction = (entityId: string) => {
return supportsIconAction ? "toggle" : "none";
};
export const DEFAULT_NAME = [
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
@customElement("hui-tile-card")
export class HuiTileCard extends LitElement implements LovelaceCard {
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 nameConfig = this._config.name;
const nameDisplay =
typeof nameConfig === "string"
? nameConfig
: this.hass.formatEntityName(stateObj, nameConfig || DEFAULT_NAME);
const name = this._config.name || computeStateName(stateObj);
const active = stateActive(stateObj);
const color = this._computeStateColor(stateObj, this._config.color);
const domain = computeDomain(stateObj.entity_id);
@@ -278,7 +267,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.stateObj=${stateObj}
.hass=${this.hass}
.content=${this._config.state_content}
.name=${nameDisplay}
.name=${this._config.name}
>
</state-display>
`;
@@ -337,7 +326,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
${renderTileBadge(stateObj, this.hass)}
</ha-tile-icon>
<ha-tile-info id="info">
<span slot="primary" class="primary">${nameDisplay}</span>
<span slot="primary" class="primary">${name}</span>
${stateDisplay
? html`<span slot="secondary">${stateDisplay}</span>`
: nothing}

View File

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

View File

@@ -1,11 +1,8 @@
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 { EnergySourceByType } from "../../../data/energy";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { Statistic, StatisticType } from "../../../data/recorder";
import type { TimeFormat } from "../../../data/translation";
import type { ForecastType } from "../../../data/weather";
import type {
FullCalendarView,
@@ -28,7 +25,9 @@ import type {
} from "../entity-rows/types";
import type { LovelaceHeaderFooterConfig } from "../header-footer/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 { EnergySourceByType } from "../../../data/energy";
export type AlarmPanelCardConfigState =
| "arm_away"
@@ -136,10 +135,8 @@ export interface ButtonCardConfig extends LovelaceCardConfig {
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
show_state?: boolean;
color?: string;
}
export interface EnergyCardBaseConfig extends LovelaceCardConfig {
@@ -534,7 +531,6 @@ export interface TodoListCardConfig extends LovelaceCardConfig {
entity?: string;
hide_completed?: boolean;
hide_create?: boolean;
hide_section_headers?: boolean;
sort?: string;
}
@@ -572,7 +568,7 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
export interface TileCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
name?: string;
hide_state?: boolean;
state_content?: string | string[];
icon?: string;

View File

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

View File

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

View File

@@ -32,8 +32,6 @@ const cardConfigStruct = assign(
double_tap_action: optional(actionConfigStruct),
theme: optional(string()),
show_state: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
})
);
@@ -48,19 +46,6 @@ export class HuiButtonCardEditor
public setConfig(config: ButtonCardConfig): void {
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;
}
@@ -68,11 +53,11 @@ export class HuiButtonCardEditor
(entityId: string | undefined) =>
[
{ name: "entity", selector: { entity: {} } },
{ name: "name", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{ name: "name", selector: { text: {} } },
{
name: "icon",
selector: {
@@ -82,18 +67,6 @@ export class HuiButtonCardEditor
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: "",
type: "grid",
schema: [
{ name: "icon_height", selector: { text: { suffix: "px" } } },
{ name: "theme", selector: { theme: {} } },
],
},
{
name: "interactions",
type: "expandable",

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,6 @@ const cardConfigStruct = assign(
entity: optional(string()),
hide_completed: optional(boolean()),
hide_create: optional(boolean()),
hide_section_headers: optional(boolean()),
display_order: optional(string()),
item_tap_action: optional(string()),
})
@@ -60,7 +59,6 @@ export class HuiTodoListEditor
{ name: "theme", selector: { theme: {} } },
{ name: "hide_completed", selector: { boolean: {} } },
{ name: "hide_create", selector: { boolean: {} } },
{ name: "hide_section_headers", selector: { boolean: {} } },
{
name: "display_order",
selector: {
@@ -133,7 +131,6 @@ export class HuiTodoListEditor
.data=${this._data(this._config)}
.schema=${this._schema(this.hass.localize, this._todoListSupportsFeature(TodoListEntityFeature.MOVE_TODO_ITEM))}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
</div>
@@ -167,7 +164,6 @@ export class HuiTodoListEditor
)})`;
case "hide_completed":
case "hide_create":
case "hide_section_headers":
case "display_order":
case "item_tap_action":
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 {
return configElementStyle;
}

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

@@ -318,7 +318,7 @@ class HUIRoot extends LitElement {
menu-corner="END"
>
<ha-icon-button
.id="button-${index}"
.label=${label}
.path=${item.icon}
slot="trigger"
></ha-icon-button>
@@ -340,9 +340,6 @@ class HUIRoot extends LitElement {
`
)}
</ha-button-menu>
<ha-tooltip placement="bottom" .for="button-${index}">
${label}
</ha-tooltip>
`
: html`
<ha-icon-button

View File

@@ -7,13 +7,7 @@ import type {
EntityConfig,
LovelaceRow,
} from "../entity-rows/types";
import { fireEvent } from "../../../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"row-visibility-changed": { row: LovelaceRow; value: boolean };
}
}
@customElement("hui-conditional-row")
class HuiConditionalRow extends HuiConditionalBase implements LovelaceRow {
public setConfig(config: ConditionalRowConfig): void {
@@ -32,15 +26,6 @@ class HuiConditionalRow extends HuiConditionalBase implements LovelaceRow {
: config.row
) as LovelaceRow;
}
protected setVisibility(conditionMet: boolean): void {
const visible = this.preview || conditionMet;
const previouslyHidden = this.hidden;
super.setVisibility(conditionMet);
if (previouslyHidden !== this.hidden) {
fireEvent(this, "row-visibility-changed", { row: this, value: visible });
}
}
}
declare global {

Some files were not shown because too many files have changed in this diff Show More