Compare commits

..

35 Commits

Author SHA1 Message Date
Pavilion
c66b83d5f6 Merge branch 'dev' into animate-ha-sidebar 2026-01-15 15:43:37 +00:00
uptimeZERO_
1d241aa49a Truncate long menu item labels in the sidebar (#29005) 2026-01-15 15:41:48 +00:00
Pegasus
fece231faf fix: restrict to exact match for data table (#28853) 2026-01-15 15:50:15 +01:00
Aidan Timson
fffb3c3a28 Migrate category dialogs to ha-wa-dialog (#29009) 2026-01-15 15:32:43 +01:00
renovate[bot]
fe14d436ff Update vitest monorepo to v4.0.17 (#29007)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:46:10 +02:00
Petar Petrov
42e02be928 Add subpage titles in for config panel pages (#28990)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-15 12:48:33 +01:00
Aydar Gumerbaev
6213b6cd2a Always use fallback for brands URL (#28994) 2026-01-15 11:42:17 +00:00
Pavilion
37e35a3026 WIP animating ha-sidebar 2026-01-15 11:35:52 +00:00
Aidan Timson
cd75c55392 Entity context: voice assistants expose entities (#28992)
* Entity context: voice assistants expose entities

* Load virtualiser

* Refactor filter entities, reduce duplicate renders

* Fix logic
2026-01-15 13:09:38 +02:00
Marcin Bauer
ca325020d7 Add Labs feature note to automation element picker (#28874)
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-15 11:09:11 +00:00
Jeremy Cook
6250402661 Fix vertical-align in markdown tables with presentation role (#29001) 2026-01-15 11:48:43 +01:00
Amit Finkelstein
0bfca79851 Stop dropdown select events from bubbling in automation rows (#28985) 2026-01-15 11:22:23 +01:00
Wendelin
49bddf6139 Automation add TCA: fix: prevent multiple dialog closures by tracking closing state (#28978) 2026-01-15 08:50:55 +01:00
Wendelin
0daf94e98f Quick bar: new design and area search (#28678)
* Add "Commands" title to quick bar translations in English

* Enhance QuickBar dialog handling and localize commands title

* add nav icons

* Add icons and styles and separate navigation from commands

* handle non admin

* Add areas

* Fix import and shortcuts

* Restructure

* remove area sort

* move keys

* area search keys review

* Fix adaptive dialog slots without header

* Design review

* Review marcin

* Fix safe area bottom

* Fix ios focus

* Make it clearable

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-01-15 09:45:57 +02:00
uptimeZERO_
00a3237611 Persist theme settings to user profile and allow migration (#28965)
* Moved theme functionality and persistence target

* fixed type mismatch

* using SubscribeMixin

* returning no-op unsub to handle rejection path

* added notification if theme save fails

* using hass instead of state

* renamed theme variable for clarity

* Added toast if theme pref is unavailable

* Always saving theme to localStorage

* Removing localStorage fallback

* Updating local cache when new theme comes from core
2026-01-15 09:40:43 +02:00
StormDev
53deb3f419 Removed uneccessary import in landing-page-network.ts (#29000)
* Removed uneccessary console on landing-page-networks.ts

* Uneccessary import removed in landing-page-networks.ts
2026-01-15 07:26:50 +00:00
Pegasus
6c1c7cead3 Fixes duplicate "Device info" section name when viewing Matter devices. (#28984)
ha-device-info-matter: rename 'Device info' to 'Matter info'

Fixes duplicate "Device info" section name when viewing Matter devices.
The nested expansion panel now displays "Matter info" consistent with
other integrations (e.g., ZHA uses "Zigbee info", Z-Wave uses "Z-Wave info").

Also adds a gallery demo for testing the component.
2026-01-15 09:18:21 +02:00
Jason Madigan
f8d65cc0ec Make entities on the energy now sankey graph clickable (#28998)
* enhancement: make entities on the energy now sankey graph clickable to show details

Signed-off-by: Jason Madigan <jason@jasonmadigan.com>

* add a test

Signed-off-by: Jason Madigan <jason@jasonmadigan.com>

* format

---------

Signed-off-by: Jason Madigan <jason@jasonmadigan.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-15 06:44:03 +00:00
Paul Bottein
5be7bad176 Allow to add context to tile card secondary line (#28995) 2026-01-14 19:48:27 +01:00
Paul Bottein
0a54a93a39 Use tabs for bluetooth panel (#28824)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-14 14:38:10 +01:00
Petar Petrov
156583aff1 Include the area when duplicating a scene from the scene dashboard (#28955) 2026-01-14 12:28:05 +01:00
Aidan Timson
7572257821 Match expose config dashboard for assistants columns (#28956) 2026-01-14 11:43:56 +01:00
Pegasus
4703cf802f Change border-quiet token values from 80 to 90 (#28976) 2026-01-14 09:28:32 +00:00
ildar170975
55c2315329 ha-label-picker: remove valueRenderer (#28975) 2026-01-14 10:15:39 +01:00
Wendelin
7d7e95ac55 Improve device automation UI (#28967)
* Improve device automation rows

* Improve device automation type picker

* Update src/panels/config/automation/condition/ha-automation-condition-row.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-14 07:06:56 +00:00
ildar170975
6d7694caff ha-label-picker, ha-category-picker: fix icon for "no items available" (#28973)
* remove NO_LABELS

* remove NO_CATEGORIES

* reverted removed icon
2026-01-14 08:42:24 +02:00
calm
d7b6243698 Fix tree view heading overlapping Show more button (#28872) (#28968) 2026-01-13 18:34:39 +01:00
calm
73feef9e92 Remove box-shadow from automation dialog "Show more" button (#28945) (#28960) 2026-01-13 17:31:55 +01:00
renovate[bot]
453a546574 Update Node.js to v24.13.0 (#28963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:16:03 +00:00
Petar Petrov
52c0e6f1f5 Respect user-configured grid options for fixed_rows/fixed_columns cards (#28961) 2026-01-13 16:24:25 +02:00
Aidan Timson
444f8d87b3 Ignore all node_modules, not just from root dir (#28959) 2026-01-13 13:51:54 +01:00
Pegasus
57a586c3a7 fix: update the z-index of search button mainly for yaml mode (#28878) 2026-01-13 13:41:53 +01:00
Pegasus
1975265e6b Update the Select Option type from any to string per documentation (#28954) 2026-01-13 10:44:02 +01:00
Wendelin
66e6cb8dbc Fix category-picker unknown check (#28957) 2026-01-13 09:39:05 +00:00
Petar Petrov
9ce9d254f8 Picture elements position by click (#28597) 2026-01-13 10:01:07 +01:00
127 changed files with 2862 additions and 2394 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ dist/
!.yarn/sdks
!.yarn/versions
.pnp.*
/node_modules/
node_modules/
yarn-error.log
npm-debug.log

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../src/data/area_registry";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAreaRegistry = (

View File

@@ -10,7 +10,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";

View File

@@ -11,7 +11,7 @@ import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";

View File

@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import memoizeOne from "memoize-one";
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";

View File

@@ -176,7 +176,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.16",
"@vitest/coverage-v8": "4.0.17",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -217,7 +217,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.52.0",
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.16",
"vitest": "4.0.17",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -236,6 +236,6 @@
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
export const computeAreaName = (area: AreaRegistryEntry): string | undefined =>
area.name?.trim();

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";

View File

@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type {
EntityRegistryDisplayEntry,

View File

@@ -1,6 +1,16 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
export const deepEqual = (a: any, b: any): boolean => {
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
if (a === b) {
return true;
}
@@ -18,7 +28,7 @@ export const deepEqual = (a: any, b: any): boolean => {
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i])) {
if (!deepEqual(a[i], b[i], options)) {
return false;
}
}
@@ -35,7 +45,7 @@ export const deepEqual = (a: any, b: any): boolean => {
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]))) {
if (!deepEqual(i[1], b.get(i[0]), options)) {
return false;
}
}
@@ -93,11 +103,28 @@ export const deepEqual = (a: any, b: any): boolean => {
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key])) {
if (!deepEqual(a[key], b[key], options)) {
return false;
}
}
// Compare Symbol properties if requested
if (options?.compareSymbols) {
const symbolsA = Object.getOwnPropertySymbols(a);
const symbolsB = Object.getOwnPropertySymbols(b);
if (symbolsA.length !== symbolsB.length) {
return false;
}
for (const sym of symbolsA) {
if (!Object.prototype.hasOwnProperty.call(b, sym)) {
return false;
}
if (!deepEqual(a[sym], b[sym], options)) {
return false;
}
}
}
return true;
}

View File

@@ -2,9 +2,13 @@ import { customElement, property, state } from "lit/decorators";
import { LitElement, html, css } from "lit";
import type { EChartsType } from "echarts/core";
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import type {
CallbackDataParams,
ECElementEvent,
} from "echarts/types/src/util/types";
import memoizeOne from "memoize-one";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts/echarts";
@@ -21,6 +25,7 @@ export interface Node {
label?: string;
color?: string;
passThrough?: boolean;
entityId?: string;
}
export interface Link {
source: string;
@@ -83,6 +88,7 @@ export class HaSankeyChart extends LitElement {
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
></ha-chart-base>`;
}
@@ -103,6 +109,22 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
if (detail.dataType !== "node") {
return;
}
const nodeId = (detail.data as Record<string, any>)?.id;
if (!nodeId) {
return;
}
const node = this.data.nodes.find((n) => n.id === nodeId);
if (node?.entityId) {
fireEvent(this, "node-click", { node });
}
};
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
@@ -294,4 +316,7 @@ declare global {
interface HTMLElementTagNameMap {
"ha-sankey-chart": HaSankeyChart;
}
interface HASSDomEvents {
"node-click": { node: Node };
}
}

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink";
import Fuse, { type FuseOptionKey } from "fuse.js";
import type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { multiTermSearch } from "../../resources/fuseMultiTerm";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,46 +11,159 @@ import type {
SortingDirection,
} from "./ha-data-table";
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
interface FilterKeyConfig {
key: string;
filterKey?: string;
}
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
column.filterKey
? `${column.valueColumn || key}.${column.filterKey}`
: key
);
}
});
return Array.from(searchKeys);
const getFilterKeys = memoizeOne(
(columns: SortableColumnContainer): FilterKeyConfig[] =>
Object.entries(columns)
.filter(([, column]) => column.filterable)
.map(([key, column]) => ({
key: column.valueColumn || key,
filterKey: column.filterKey,
}))
);
const getSearchableValue = (
row: DataTableRowData,
{ key, filterKey }: FilterKeyConfig
): string => {
let value = row[key];
if (value == null) {
return "";
}
);
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
if (filterKey && typeof value === "object" && !Array.isArray(value)) {
value = value[filterKey];
if (value == null) {
return "";
}
}
if (Array.isArray(value)) {
const stringValues = value
.filter((item) => item != null && typeof item !== "object")
.map(String);
return stripDiacritics(stringValues.join(" ").toLowerCase());
}
return stripDiacritics(String(value).toLowerCase());
};
/** Filters data using exact substring matching (all terms must match). */
const filterDataExact = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
if (terms.length === 1) {
const term = terms[0];
return data.filter((row) =>
filterKeys.some((config) =>
getSearchableValue(row, config).includes(term)
)
);
}
return data.filter((row) => {
const searchString = filterKeys
.map((config) => getSearchableValue(row, config))
.join(" ");
return terms.every((term) => searchString.includes(term));
});
};
const FUZZY_OPTIONS: IFuseOptions<DataTableRowData> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.2, // Stricter than default 0.3
minMatchCharLength: 2,
ignoreLocation: true,
shouldSort: false,
};
interface FuseKeyConfig {
name: string | string[];
getFn: (row: DataTableRowData) => string;
}
/** Filters data using fuzzy matching with Fuse.js (all terms must match). */
const filterDataFuzzy = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
// Build Fuse.js search keys from filter keys
const fuseKeys: FuseKeyConfig[] = filterKeys.map((config) => ({
name: config.filterKey ? [config.key, config.filterKey] : config.key,
getFn: (row: DataTableRowData) => getSearchableValue(row, config),
}));
// Find minimum term length to adjust minMatchCharLength
const minTermLength = Math.min(...terms.map((t) => t.length));
const minMatchCharLength = Math.min(minTermLength, 2);
const fuse = new Fuse<DataTableRowData>(data, {
...FUZZY_OPTIONS,
keys: fuseKeys as FuseOptionKey<DataTableRowData>[],
minMatchCharLength,
});
// For single term, simple search
if (terms.length === 1) {
return fuse.search(terms[0]).map((r) => r.item);
}
// For multiple terms, all must match (AND logic)
const expression = {
$and: terms.map((term) => ({
$or: fuseKeys.map((key) => ({
$path: Array.isArray(key.name) ? key.name : [key.name],
$val: term,
})),
})),
};
return fuse.search(expression).map((r) => r.item);
};
/**
* Filters data with exact match priority and fuzzy fallback.
* - First tries exact substring matching
* - If exact matches found, returns only those
* - If no exact matches, falls back to fuzzy search with strict scoring
*/
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
filter = stripDiacritics(filter.toLowerCase());
): DataTableRowData[] => {
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
if (filter === "") {
if (!normalizedFilter) {
return data;
}
const keys = getSearchKeys(columns);
const filterKeys = getFilterKeys(columns);
const index = fuseIndex(data, keys);
if (!filterKeys.length) {
return data;
}
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
const terms = normalizedFilter.split(/\s+/);
// First, try exact substring matching
const exactMatches = filterDataExact(data, filterKeys, terms);
if (exactMatches.length > 0) {
return exactMatches;
}
// No exact matches, fall back to fuzzy search
return filterDataFuzzy(data, filterKeys, terms);
};
const sortData = (

View File

@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -11,11 +12,12 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-generic-picker";
import "../ha-md-select";
import "../ha-md-select-option";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
export abstract class HaDeviceAutomationPicker<
T extends DeviceAutomation,
@@ -28,7 +30,7 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@state() private _automations: T[] = [];
@state() private _automations?: T[];
// Trigger an empty render so we start with a clean DOM.
// paper-listbox does not like changing things around.
@@ -44,12 +46,6 @@ export abstract class HaDeviceAutomationPicker<
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
);
}
private _localizeDeviceAutomation: (
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
@@ -75,7 +71,7 @@ export abstract class HaDeviceAutomationPicker<
}
private get _value() {
if (!this.value) {
if (!this.value || !this._automations) {
return "";
}
@@ -88,7 +84,7 @@ export abstract class HaDeviceAutomationPicker<
);
if (idx === -1) {
return UNKNOWN_AUTOMATION_KEY;
return this.value.alias || this.value.type || "unknown";
}
return `${this._automations[idx].device_id}_${idx}`;
@@ -99,37 +95,21 @@ export abstract class HaDeviceAutomationPicker<
return nothing;
}
const value = this._value;
return html`
<ha-md-select
.label=${this.label}
.value=${value}
@change=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY
? html`<ha-md-select-option .value=${NO_AUTOMATION_KEY}>
${this.NO_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${value === UNKNOWN_AUTOMATION_KEY
? html`<ha-md-select-option .value=${UNKNOWN_AUTOMATION_KEY}>
${this.UNKNOWN_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${this._automations.map(
(automation, idx) => html`
<ha-md-select-option .value=${`${automation.device_id}_${idx}`}>
${this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
)}
</ha-md-select-option>
`
)}
</ha-md-select>
`;
return html`<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
)}
hide-clear-icon
>
</ha-generic-picker>`;
}
protected updated(changedProps) {
@@ -140,6 +120,57 @@ export abstract class HaDeviceAutomationPicker<
}
}
private _getItems = memoizeOne(
(value: string, automations: T[] | undefined) => {
if (!automations) {
return () => undefined;
}
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
);
return {
id: `${automation.device_id}_${idx}`,
primary,
};
});
automationListItems.sort((a, b) =>
caseInsensitiveStringCompare(
a.primary,
b.primary,
this.hass.locale.language
)
);
if (value === NO_AUTOMATION_KEY) {
automationListItems.unshift({
id: NO_AUTOMATION_KEY,
primary: this.NO_AUTOMATION_TEXT,
});
}
return () => automationListItems;
}
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
return html`<span slot="headline">${text}</span>`;
};
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
@@ -161,13 +192,14 @@ export abstract class HaDeviceAutomationPicker<
this._renderEmpty = false;
}
private _automationChanged(ev) {
const value = ev.target.value;
if (!value || [UNKNOWN_AUTOMATION_KEY, NO_AUTOMATION_KEY].includes(value)) {
private _automationChanged(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value || NO_AUTOMATION_KEY === value) {
return;
}
const [deviceId, idx] = value.split("_");
const automation = this._automations[idx];
const automation = this._automations![idx];
if (automation.device_id !== deviceId) {
return;
}

View File

@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import {
STATE_DISPLAY_SPECIAL_CONTENT,
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
@@ -95,6 +96,9 @@ export class HaStateContentPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-name" }) public allowName =
false;
@property({ type: Boolean, attribute: "allow-context" }) public allowContext =
false;
@property() public label?: string;
@property() public value?: string[] | string;
@@ -106,7 +110,12 @@ export class HaStateContentPicker extends LitElement {
private _editIndex?: number;
private _getItems = memoizeOne(
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
(
entityId?: string,
stateObj?: HassEntity,
allowName?: boolean,
allowContext?: boolean
) => {
const domain = entityId ? computeDomain(entityId) : undefined;
const items: PickerComboBoxItem[] = [
{
@@ -149,6 +158,52 @@ export class HaStateContentPicker extends LitElement {
"ui.components.state-content-picker.last_updated"
),
},
...(allowContext && stateObj
? (() => {
const context = getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const contextItems: PickerComboBoxItem[] = [];
if (context.device) {
contextItems.push({
id: "device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
primary: this.hass.localize(
"ui.components.state-content-picker.area_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.area_name"
),
});
}
if (context.floor) {
contextItems.push({
id: "floor_name",
primary: this.hass.localize(
"ui.components.state-content-picker.floor_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.floor_name"
),
});
}
return contextItems;
})()
: []),
...(domain
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
@@ -300,7 +355,8 @@ export class HaStateContentPicker extends LitElement {
const items = this._getItems(
this.entityId,
stateObjForItems,
this.allowName
this.allowName,
this.allowContext
);
return items.find((item) => item.id === value)?.primary;
}
@@ -343,7 +399,12 @@ export class HaStateContentPicker extends LitElement {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
@@ -367,7 +428,12 @@ export class HaStateContentPicker extends LitElement {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
// If the search string does not match with the id of any of the items,
// offer to add it as a custom attribute

View File

@@ -1,8 +1,8 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { listenMediaQuery } from "../common/dom/media_query";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -88,6 +88,9 @@ export class HaAdaptiveDialog extends LitElement {
@property({ type: Boolean, attribute: "block-mode-change" })
public blockModeChange = false;
@property({ type: Boolean, attribute: "without-header" })
public withoutHeader = false;
@state() private _mode: DialogSheetMode = "dialog";
private _unsubMediaQuery?: () => void;
@@ -118,27 +121,33 @@ export class HaAdaptiveDialog extends LitElement {
if (this._mode === "bottom-sheet") {
return html`
<ha-bottom-sheet .open=${this.open} flexcontent>
<ha-dialog-header
slot="header"
.subtitlePosition=${this.headerSubtitlePosition}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-drawer="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
${!this.withoutHeader
? html`<ha-dialog-header
slot="header"
.subtitlePosition=${this.headerSubtitlePosition}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-drawer="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle !== undefined
? html`<span
slot="title"
class="title"
id="ha-wa-dialog-title"
>
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>`
: nothing}
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-bottom-sheet>
@@ -156,6 +165,7 @@ export class HaAdaptiveDialog extends LitElement {
.headerSubtitle=${this.headerSubtitle}
.headerSubtitlePosition=${this.headerSubtitlePosition}
flexcontent
.withoutHeader=${this.withoutHeader}
>
<slot name="headerNavigationIcon" slot="headerNavigationIcon">
<ha-icon-button

View File

@@ -6,16 +6,10 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import { areaComboBoxKeys, getAreas } from "../data/area/area_picker";
import { createAreaRegistryEntry } from "../data/area/area_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
@@ -30,12 +24,6 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "search_labels.areaName", weight: 10 },
{ name: "search_labels.aliases", weight: 8 },
{ name: "search_labels.floorName", weight: 6 },
{ name: "search_labels.id", weight: 3 },
];
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -102,6 +90,8 @@ export class HaAreaPicker extends LitElement {
await this._picker?.open();
}
private _getAreasMemoized = memoizeOne(getAreas);
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
@@ -137,183 +127,13 @@ export class HaAreaPicker extends LitElement {
}
);
private _getAreas = memoizeOne(
(
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"]
): PickerComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
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)
);
}
const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
id: area.area_id,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
search_labels: {
areaName: areaName || null,
floorName: floorName || null,
id: area.area_id,
aliases: area.aliases.join(" "),
},
};
});
return items;
}
);
private _getItems = () =>
this._getAreas(
this._getAreasMemoized(
this.hass.areas,
this.hass.floors,
this.hass.devices,
this.hass.entities,
this.hass.states,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -394,7 +214,7 @@ export class HaAreaPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
.searchKeys=${SEARCH_KEYS}
.searchKeys=${areaComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}

View File

@@ -255,6 +255,7 @@ export class HaCodeEditor extends ReactiveElement {
...this._loadedCodeMirror.tabKeyBindings,
saveKeyBinding,
]),
this._loadedCodeMirror.search({ top: true }),
this._loadedCodeMirror.langCompartment.of(this._mode),
this._loadedCodeMirror.haTheme,
this._loadedCodeMirror.haSyntaxHighlighting,

View File

@@ -90,6 +90,16 @@ export class HaDrawer extends DrawerBase {
border-color: var(--divider-color, rgba(0, 0, 0, 0.12));
inset-inline-start: 0 !important;
inset-inline-end: initial !important;
transition-property: transform, width;
transition-duration:
var(--mdc-drawer-transition-duration, 0.2s),
var(--ha-animation-base-duration);
transition-timing-function:
var(
--mdc-drawer-transition-timing-function,
cubic-bezier(0.4, 0, 0.2, 1)
),
ease;
}
.mdc-drawer.mdc-drawer--modal.mdc-drawer--open {
z-index: 200;
@@ -103,6 +113,15 @@ export class HaDrawer extends DrawerBase {
direction: var(--direction);
width: 100%;
box-sizing: border-box;
transition:
padding-left var(--ha-animation-base-duration) ease,
padding-inline-start var(--ha-animation-base-duration) ease;
}
@media (prefers-reduced-motion: reduce) {
.mdc-drawer,
.mdc-drawer-app-content {
transition: none;
}
}
`,
];

View File

@@ -8,7 +8,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { updateAreaRegistryEntry } from "../data/area_registry";
import { updateAreaRegistryEntry } from "../data/area/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,

View File

@@ -1,4 +1,4 @@
import { mdiLabel, mdiPlus } from "@mdi/js";
import { mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
@@ -25,11 +25,9 @@ import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS = "___NO_LABELS___";
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@@ -108,52 +106,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
];
}
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
if (!labels) {
return new Map();
}
return new Map(labels.map((label) => [label.label_id, label]));
}
);
private _computeValueRenderer = memoizeOne(
(labels: LabelRegistryEntry[] | undefined): PickerValueRenderer =>
(value) => {
const label = this._labelMap(labels).get(value);
if (!label) {
return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
}
);
private _getLabelsMemoized = memoizeOne(getLabels);
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,
},
];
}
return this._getLabelsMemoized(
private _getItems = () =>
this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
@@ -166,7 +122,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -219,8 +174,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html`
<ha-generic-picker
.disabled=${this.disabled}
@@ -237,7 +190,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
@@ -251,10 +203,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_LABELS) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -138,10 +138,10 @@ export class HaMarkdown extends LitElement {
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th {
vertical-align: attr(align, center);
vertical-align: attr(valign, middle);
}
td {
vertical-align: attr(align, left);
vertical-align: attr(valign, middle);
}
}
table {

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import { mdiClose, mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -26,6 +26,8 @@ import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -147,7 +149,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@property({ type: Boolean, reflect: true }) public clearable = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@@ -160,7 +164,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
return this.virtualizerElement as HTMLElement | null;
}
@state() private _sectionTitle?: string;
@@ -207,8 +211,17 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return html`<ha-textfield
.label=${searchLabel}
@blur=${this._resetSelectedItem}
@input=${this._filterChanged}
></ha-textfield>
.iconTrailing=${this.clearable && !!this._search}
>
<ha-icon-button
@click=${this._clearSearch}
slot="trailingIcon"
.label=${this.hass?.localize("ui.common.clear") || "Clear"}
.path=${mdiClose}
></ha-icon-button>
</ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
@@ -244,6 +257,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
@@ -276,18 +290,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@eventOptions({ passive: true })
private _visibilityChanged(ev) {
if (
this._virtualizerElement &&
this.virtualizerElement &&
this.sectionTitleFunction &&
this.sections?.length
) {
const firstItem = this._virtualizerElement.items[ev.first];
const secondItem = this._virtualizerElement.items[ev.first + 1];
const firstItem = this.virtualizerElement.items[ev.first];
const secondItem = this.virtualizerElement.items[ev.first + 1];
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem,
secondItem: secondItem as PickerComboBoxItem,
itemsCount: this._virtualizerElement.items.length,
itemsCount: this.virtualizerElement.items.length,
});
}
}
@@ -403,9 +417,22 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _valueSelected = (ev: Event) => {
ev.stopPropagation();
const value = (ev.currentTarget as any).value as string;
const index = Number((ev.currentTarget as any).index);
const newValue = value?.trim();
fireEvent(this, "value-changed", { value: newValue });
this._fireSelectedEvents(newValue, index);
};
private _fireSelectedEvents(value: string, index: number) {
fireEvent(this, "value-changed", { value });
fireEvent(this, "index-selected", { index });
}
private _clearSearch = () => {
if (this._searchFieldElement) {
this._searchFieldElement.value = "";
this._searchFieldElement.dispatchEvent(new Event("input"));
}
};
private _fuseIndex = memoizeOne(
@@ -487,8 +514,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._items = this._getItems();
// Reset scroll position when filter changes
if (this._virtualizerElement) {
this._virtualizerElement.scrollToIndex(0);
if (this.virtualizerElement) {
this.virtualizerElement.scrollToIndex(0);
}
}
@@ -511,13 +538,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this._virtualizerElement) {
if (!this.virtualizerElement) {
return;
}
this._searchFieldElement?.focus();
const items = this._virtualizerElement.items as PickerComboBoxItem[];
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const maxItems = items.length - 1;
@@ -551,14 +578,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this._virtualizerElement) {
if (!this.virtualizerElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this._virtualizerElement.items as PickerComboBoxItem[];
const items = this.virtualizerElement.items as PickerComboBoxItem[];
if (!items[nextIndex]) {
return;
@@ -580,13 +607,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
return;
}
const nextIndex = 0;
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -597,13 +624,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
return;
}
const nextIndex = this._virtualizerElement.items.length - 1;
const nextIndex = this.virtualizerElement.items.length - 1;
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -613,14 +640,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _scrollToSelectedItem = () => {
this._virtualizerElement
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
this.virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
requestAnimationFrame(() => {
this._virtualizerElement
this.virtualizerElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
@@ -628,12 +655,20 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickSelectedItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (this._virtualizerElement?.items.length === 1) {
fireEvent(this, "value-changed", {
value: firstItem.id,
if (
this.virtualizerElement?.items?.length !== undefined &&
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
this.virtualizerElement.items.filter((item) => typeof item !== "string")
.length === 1
) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
if (typeof item !== "string") {
this._fireSelectedEvents(item.id, index);
}
});
return;
}
if (this._selectedItemIndex === -1) {
@@ -643,16 +678,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
// if filter button is focused
ev.preventDefault();
const item = this._virtualizerElement?.items[
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item) {
fireEvent(this, "value-changed", { value: item.id });
this._fireSelectedEvents(item.id, this._selectedItemIndex);
}
};
private _resetSelectedItem() {
this._virtualizerElement
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._selectedItemIndex = -1;
@@ -662,11 +697,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (!this._virtualizerElement || this._search || !this.value) {
if (!this.virtualizerElement || this._search || !this.value) {
return 0;
}
const index = this._virtualizerElement.items.findIndex(
const index = this.virtualizerElement.items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
@@ -691,6 +726,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
flex: 1;
}
:host([clearable]) {
--text-field-padding: 0 0 0 var(--ha-space-4);
}
ha-textfield {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
@@ -792,8 +831,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-title,
.title {
box-sizing: border-box;
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-2) var(--ha-space-3);
padding: var(--ha-space-1) var(--ha-space-4);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
@@ -822,7 +862,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
width: calc(100% - var(--ha-space-4));
}
.section-title.show {
@@ -846,4 +886,8 @@ declare global {
interface HTMLElementTagNameMap {
"ha-picker-combo-box": HaPickerComboBox;
}
interface HASSDomEvents {
"index-selected": { index: number };
}
}

View File

@@ -223,7 +223,6 @@ export class HaRelatedItems extends LitElement {
.src=${brandsUrl({
domain: entry.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
@@ -249,7 +248,6 @@ export class HaRelatedItems extends LitElement {
.src=${brandsUrl({
domain: integration,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -87,7 +87,6 @@ export class HaMediaSelector extends LitElement {
this._thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnail),
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
});
} else {

View File

@@ -37,6 +37,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) {
.disabled=${this.disabled}
.required=${this.required}
.allowName=${this.selector.ui_state_content?.allow_name || false}
.allowContext=${this.selector.ui_state_content?.allow_context || false}
></ha-entity-state-content-picker>
`;
}

View File

@@ -589,10 +589,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
// On keypresses on the listbox, we're going to ignore mouse enter events
// for 100ms so that we ignore it when pressing down arrow scrolls the
// sidebar causing the mouse to hover a new icon
if (
this.alwaysExpand ||
new Date().getTime() < this._recentKeydownActiveUntil
) {
if (new Date().getTime() < this._recentKeydownActiveUntil) {
return;
}
if (this._mouseLeaveTimeout) {
@@ -612,7 +609,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _listboxFocusIn(ev) {
if (this.alwaysExpand || ev.target.localName !== "ha-md-list-item") {
if (ev.target.localName !== "ha-md-list-item") {
return;
}
this._showTooltip(ev.target);
@@ -652,6 +649,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = undefined;
}
const itemText = item.querySelector(".item-text") as HTMLElement | null;
if (this.hasAttribute("expanded") && itemText) {
const isTruncated = itemText.scrollWidth > itemText.clientWidth;
if (!isTruncated) {
this._hideTooltip();
return;
}
}
const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item));
@@ -662,9 +667,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement
).innerText;
tooltip.innerText = itemText?.innerText ?? "";
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
@@ -728,6 +731,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
font-size: var(--ha-font-size-xl);
align-items: center;
overflow: hidden;
width: calc(56px + var(--safe-area-inset-left, 0px));
padding-left: calc(
var(--ha-space-1) + var(--safe-area-inset-left, 0px)
);
@@ -736,6 +741,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
padding-inline-end: initial;
padding-top: var(--safe-area-inset-top, 0px);
transition: width var(--ha-animation-base-duration) ease;
}
:host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left, 0px));
@@ -750,15 +756,28 @@ class HaSidebar extends SubscribeMixin(LitElement) {
margin-left: 3px;
margin-inline-start: 3px;
margin-inline-end: initial;
width: 100%;
display: none;
flex: 1;
min-width: 0;
max-width: 0;
opacity: 0;
transform: translateX(-4px);
transition:
max-width var(--ha-animation-base-duration) ease,
opacity var(--ha-animation-base-duration) ease,
transform var(--ha-animation-base-duration) ease;
}
:host([narrow]) .title {
margin: 0;
padding: 0 var(--ha-space-4);
}
:host([expanded]) .title {
display: initial;
max-width: 100%;
opacity: 1;
transform: none;
transition-delay: 0ms, 80ms, 80ms;
}
:host(:not([expanded])) .title {
margin: 0;
}
.hidden-panel {
display: none;
@@ -800,6 +819,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-icon-size: var(--ha-space-6);
transition: width var(--ha-animation-base-duration) ease;
}
:host([expanded]) ha-md-list-item {
width: 248px;
@@ -840,12 +860,29 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
ha-md-list-item .item-text {
display: none;
display: block;
max-width: 0;
opacity: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transform: translateX(-4px);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
transition:
max-width var(--ha-animation-base-duration) ease,
opacity var(--ha-animation-base-duration) ease,
transform var(--ha-animation-base-duration) ease;
}
:host([expanded]) ha-md-list-item .item-text {
max-width: 100%;
opacity: 1;
transform: none;
transition-delay: 0ms, 80ms, 80ms;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.divider {
@@ -913,7 +950,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
position: absolute;
opacity: 0.9;
border-radius: var(--ha-border-radius-sm);
white-space: nowrap;
max-width: calc(var(--ha-space-20) * 3);
white-space: normal;
overflow-wrap: break-word;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);
@@ -924,6 +963,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
-webkit-transform: scaleX(var(--scale-direction));
transform: scaleX(var(--scale-direction));
}
@media (prefers-reduced-motion: reduce) {
.menu,
ha-md-list-item,
ha-md-list-item .item-text,
.title {
transition: none;
}
}
`,
];
}

View File

@@ -1,7 +1,7 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import type WaDialog from "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -106,6 +106,9 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@property({ type: Boolean, attribute: "without-header" })
public withoutHeader = false;
@state()
private _open = false;
@@ -147,29 +150,35 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<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 !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>
${!this.withoutHeader
? html` <slot name="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<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 !== undefined
? html`<span
slot="title"
class="title"
id="ha-wa-dialog-title"
>
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>`
: nothing}
<div class="content-wrapper">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>

View File

@@ -793,7 +793,6 @@ export class HaMediaPlayerBrowse extends LitElement {
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
});
}

View File

@@ -20,7 +20,7 @@ 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 type { AreaRegistryEntry } from "../../data/area_registry";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";

View File

@@ -0,0 +1,204 @@
import { mdiTextureBox } from "@mdi/js";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getAreaContext } from "../../common/entity/context/get_area_context";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "../device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
import type { EntityRegistryDisplayEntry } from "../entity/entity_registry";
export const getAreas = (
haAreas: HomeAssistant["areas"],
haFloors: HomeAssistant["floors"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
haStates: HomeAssistant["states"],
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[],
idPrefix = ""
): PickerComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
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 = haStates[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = haStates[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 = haStates[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = haStates[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)
);
}
const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, haFloors);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
id: `${idPrefix}${area.area_id}`,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
search_labels: {
areaId: area.area_id,
aliases: area.aliases.join(" "),
},
};
});
return items;
};
export const areaComboBoxKeys: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "search_labels.aliases",
weight: 8,
},
{
name: "secondary",
weight: 6,
},
{
name: "search_labels.domain",
weight: 4,
},
{
name: "search_labels.areaId",
weight: 2,
},
];

View File

@@ -1,12 +1,12 @@
import type { HomeAssistant } from "../types";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type { HomeAssistant } from "../../types";
import type { DeviceRegistryEntry } from "../device/device_registry";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity/entity_registry";
import type { RegistryEntry } from "./registry";
} from "../entity/entity_registry";
import type { RegistryEntry } from "../registry";
export { subscribeAreaRegistry } from "./ws-area_registry";
export { subscribeAreaRegistry } from "../ws-area_registry";
export interface AreaRegistryEntry extends RegistryEntry {
aliases: string[];

View File

@@ -6,7 +6,7 @@ import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-dev
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import type { AreaRegistryEntry } from "./area/area_registry";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,

View File

@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { RegistryEntry } from "./registry";
export { subscribeAreaRegistry } from "./ws-area_registry";

View File

@@ -11,6 +11,8 @@ import {
mdiViewDashboard,
} from "@mdi/js";
import type { HomeAssistant, PanelInfo } from "../types";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { LocalizeKeys } from "../common/translations/localize";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace";
@@ -72,6 +74,40 @@ export const getPanelTitleFromUrlPath = (
return getPanelTitle(hass, panel);
};
/**
* Get subpage title for config panel routes.
* Returns the specific subpage title (e.g., "Automations") if found,
* or undefined to fall back to the panel title (e.g., "Settings").
*
* @param hass HomeAssistant instance
* @param path Full route path (e.g., "/config/automation/dashboard")
* @param configSections Config sections metadata for resolving subpage titles
* @returns Localized subpage title, or undefined if not found
*/
export const getConfigSubpageTitle = (
hass: HomeAssistant,
path: string,
configSections: Record<string, PageNavigation[]>
): string | undefined => {
// Search through all config section groups for a matching path
for (const sectionGroup of Object.values(configSections)) {
const pageNav = sectionGroup.find((nav) => path.startsWith(nav.path));
if (pageNav) {
if (pageNav.translationKey) {
const localized = hass.localize(pageNav.translationKey as LocalizeKeys);
if (localized) {
return localized;
}
}
if (pageNav.name) {
return pageNav.name;
}
}
}
return undefined;
};
export const getPanelIcon = (panel: PanelInfo): string | undefined => {
if (!panel.icon) {
switch (panel.component_name) {

327
src/data/quick_bar.ts Normal file
View File

@@ -0,0 +1,327 @@
import {
mdiKeyboard,
mdiNavigationVariant,
mdiPuzzle,
mdiReload,
mdiServerNetwork,
mdiStorePlus,
} from "@mdi/js";
import { canShowPage } from "../common/config/can_show_page";
import { componentsWithService } from "../common/config/components_with_service";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import { configSections } from "../panels/config/ha-panel-config";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { domainToName } from "./integration";
import { getPanelIcon, getPanelNameTranslationKey } from "./panel";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
export interface NavigationComboBoxItem extends PickerComboBoxItem {
path: string;
image?: string;
iconColor?: string;
}
export interface BaseNavigationCommand {
path: string;
primary: string;
icon_path?: string;
iconPath?: string;
iconColor?: string;
image?: string;
}
export interface ActionCommandComboBoxItem extends PickerComboBoxItem {
action: string;
domain?: string;
}
export interface NavigationInfo extends PageNavigation {
primary: string;
}
const generateNavigationPanelCommands = (
localize: HomeAssistant["localize"],
panels: HomeAssistant["panels"],
addons?: HassioAddonInfo[]
): BaseNavigationCommand[] =>
Object.entries(panels)
.filter(
([panelKey]) => panelKey !== "_my_redirect" && panelKey !== "hassio"
)
.map(([_panelKey, panel]) => {
const translationKey = getPanelNameTranslationKey(panel);
const icon = getPanelIcon(panel) || "mdi:view-dashboard";
const primary = localize(translationKey) || panel.title || panel.url_path;
let image: string | undefined;
if (addons) {
const addon = addons.find(({ slug }) => slug === panel.url_path);
if (addon) {
image = addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined;
}
}
return {
primary,
icon,
image,
path: `/${panel.url_path}`,
};
});
const getNavigationInfoFromConfig = (
localize: HomeAssistant["localize"],
page: PageNavigation
): NavigationInfo | undefined => {
const path = page.path.substring(1);
let name = path.substring(path.indexOf("/") + 1);
name = name.indexOf("/") > -1 ? name.substring(0, name.indexOf("/")) : name;
const caption =
(name && localize(`ui.dialogs.quick-bar.commands.navigation.${name}`)) ||
// @ts-expect-error
(page.translationKey && localize(page.translationKey));
if (caption) {
return { ...page, primary: caption };
}
return undefined;
};
const generateNavigationConfigSectionCommands = (
hass: HomeAssistant
): BaseNavigationCommand[] => {
if (!hass.user?.is_admin) {
return [];
}
const items: NavigationInfo[] = [];
Object.values(configSections).forEach((sectionPages) => {
sectionPages.forEach((page) => {
if (!canShowPage(hass, page)) {
return;
}
const info = getNavigationInfoFromConfig(hass.localize, page);
if (!info) {
return;
}
// Add to list, but only if we do not already have an entry for the same path and component
if (items.some((e) => e.path === info.path)) {
return;
}
items.push(info);
});
});
return items;
};
const finalizeNavigationCommands = (
localize: HomeAssistant["localize"],
items: BaseNavigationCommand[]
): NavigationComboBoxItem[] =>
items.map((item, index) => {
const secondary = localize(
"ui.dialogs.quick-bar.commands.types.navigation"
);
return {
id: `navigation_${index}_${item.path}`,
icon_path: item.iconPath || mdiNavigationVariant,
secondary,
sorting_label: `${item.primary}_${secondary}`,
...item,
};
});
export const generateNavigationCommands = (
hass: HomeAssistant,
addons?: HassioAddonInfo[]
): NavigationComboBoxItem[] => {
const panelItems = generateNavigationPanelCommands(
hass.localize,
hass.panels,
addons
);
const sectionItems = generateNavigationConfigSectionCommands(hass);
const supervisorItems: BaseNavigationCommand[] = [];
if (hass.user?.is_admin && isComponentLoaded(hass, "hassio")) {
supervisorItems.push({
path: "/hassio/store",
icon_path: mdiStorePlus,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.addon_store"
),
});
supervisorItems.push({
path: "/hassio/dashboard",
icon_path: mdiPuzzle,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.addon_dashboard"
),
});
if (addons) {
for (const addon of addons.filter((a) => a.version)) {
supervisorItems.push({
path: `/hassio/addon/${addon.slug}`,
image: addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.addon_info",
{ addon: addon.name }
),
});
}
}
}
const additionalItems = [
{
path: "",
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.shortcuts"
),
icon_path: mdiKeyboard,
},
];
return finalizeNavigationCommands(hass.localize, [
...panelItems,
...sectionItems,
...supervisorItems,
...additionalItems,
]);
};
const generateReloadCommands = (
hass: HomeAssistant
): ActionCommandComboBoxItem[] => {
// Get all domains that have a direct "reload" service
const reloadableDomains = componentsWithService(hass, "reload");
const commands = reloadableDomains.map((domain) => ({
primary:
hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) ||
hass.localize("ui.dialogs.quick-bar.commands.reload.reload", {
domain: domainToName(hass.localize, domain),
}),
domain,
action: "reload",
icon_path: mdiReload,
secondary: hass.localize(`ui.dialogs.quick-bar.commands.types.reload`),
}));
// Add "frontend.reload_themes"
commands.push({
primary: hass.localize("ui.dialogs.quick-bar.commands.reload.themes"),
domain: "frontend",
action: "reload_themes",
icon_path: mdiReload,
secondary: hass.localize("ui.dialogs.quick-bar.commands.types.reload"),
});
// Add "homeassistant.reload_core_config"
commands.push({
primary: hass.localize("ui.dialogs.quick-bar.commands.reload.core"),
domain: "homeassistant",
action: "reload_core_config",
icon_path: mdiReload,
secondary: hass.localize("ui.dialogs.quick-bar.commands.types.reload"),
});
// Add "homeassistant.reload_all"
commands.push({
primary: hass.localize("ui.dialogs.quick-bar.commands.reload.all"),
domain: "homeassistant",
action: "reload_all",
icon_path: mdiReload,
secondary: hass.localize("ui.dialogs.quick-bar.commands.types.reload"),
});
return commands.map((command, index) => ({
...command,
id: `command_${index}_${command.primary}`,
sorting_label: `${command.primary}_${command.secondary}_${command.domain}`,
}));
};
const generateServerControlCommands = (
hass: HomeAssistant
): ActionCommandComboBoxItem[] => {
const serverActions = ["restart", "stop"] as const;
return serverActions.map((action, index) => {
const primary = hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
{
action: hass.localize(
`ui.dialogs.quick-bar.commands.server_control.${action}`
),
}
);
const secondary = hass.localize(
"ui.dialogs.quick-bar.commands.types.server_control"
);
return {
id: `server_control_${index}_${action}`,
primary,
domain: "homeassistant",
icon_path: mdiServerNetwork,
secondary,
sorting_label: `${primary}_${secondary}_${action}`,
action,
};
});
};
export const generateActionCommands = (
hass: HomeAssistant
): ActionCommandComboBoxItem[] => [
...generateReloadCommands(hass),
...generateServerControlCommands(hass),
];
export const commandComboBoxKeys: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "domain",
weight: 8,
},
{
name: "secondary",
weight: 6,
},
];
export const navigateComboBoxKeys: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "path",
weight: 8,
},
{
name: "secondary",
weight: 6,
},
];

View File

@@ -377,7 +377,7 @@ interface SelectBoxOptionImage {
}
export interface SelectOption {
value: any;
value: string;
label: string;
description?: string;
image?: string | SelectBoxOptionImage;
@@ -501,6 +501,7 @@ export interface UiStateContentSelector {
ui_state_content: {
entity_id?: string;
allow_name?: boolean;
allow_context?: boolean;
} | null;
}

View File

@@ -3,8 +3,8 @@ import { computeDomain } from "../common/entity/compute_domain";
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/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { AreaRegistryEntry } from "./area_registry";
import type { DevicePickerItem } from "./device/device_picker";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";

18
src/data/theme.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { HomeAssistant, ThemeSettings } from "../types";
import { saveFrontendUserData, subscribeFrontendUserData } from "./frontend";
declare global {
interface FrontendUserData {
theme: ThemeSettings;
}
}
export const subscribeThemePreferences = (
hass: HomeAssistant,
callback: (data: { value: ThemeSettings | null }) => void
) => subscribeFrontendUserData(hass.connection, "theme", callback);
export const saveThemePreferences = (
hass: HomeAssistant,
data: ThemeSettings
) => saveFrontendUserData(hass.connection, "theme", data);

View File

@@ -2,7 +2,7 @@ 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 { debounce } from "../common/util/debounce";
import type { AreaRegistryEntry } from "./area_registry";
import type { AreaRegistryEntry } from "./area/area_registry";
const fetchAreaRegistry = (conn: Connection) =>
conn.sendMessagePromise<AreaRegistryEntry[]>({

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
import { fireEvent } from "../../common/dom/fire_event";
export const enum QuickBarMode {
Command = "command",
Device = "device",
Entity = "entity",
}
export type QuickBarSection =
| "entity"
| "device"
| "area"
| "navigate"
| "command";
export interface QuickBarParams {
entityFilter?: string;
mode?: QuickBarMode;
mode?: QuickBarSection;
hint?: string;
}

View File

@@ -1,11 +1,10 @@
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-alert";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import { haStyleDialog } from "../../resources/styles";
import "../../components/ha-wa-dialog";
import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
@@ -38,6 +37,10 @@ const _SHORTCUTS: Section[] = [
{
textTranslationKey: "ui.dialogs.shortcuts.searching.on_any_page",
},
{
shortcut: [CTRL_CMD, "K"],
descriptionTranslationKey: "ui.dialogs.shortcuts.searching.search",
},
{
shortcut: ["C"],
descriptionTranslationKey:
@@ -165,17 +168,22 @@ const _SHORTCUTS: Section[] = [
class DialogShortcuts extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _open = false;
public async showDialog(): Promise<void> {
this._opened = true;
this._open = true;
}
public async closeDialog(): Promise<void> {
this._opened = false;
private _dialogClosed() {
this._open = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public async closeDialog() {
this._open = false;
return true;
}
private _renderShortcut(
shortcutKeys: ShortcutString[],
descriptionKey: LocalizeKeys
@@ -202,20 +210,11 @@ class DialogShortcuts extends LitElement {
}
protected render() {
if (!this._opened) {
return nothing;
}
return html`
<ha-dialog
open
hideActions
@closed=${this.closeDialog}
defaultAction="ignore"
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.dialogs.shortcuts.title")
)}
<ha-wa-dialog
.open=${this._open}
@closed=${this._dialogClosed}
.headerTitle=${this.hass.localize("ui.dialogs.shortcuts.title")}
>
<div class="content">
${_SHORTCUTS.map(
@@ -238,7 +237,7 @@ class DialogShortcuts extends LitElement {
)}
</div>
<ha-alert>
<ha-alert slot="footer">
${this.hass.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/general#shortcuts"
>${this.hass.localize(
@@ -247,25 +246,12 @@ class DialogShortcuts extends LitElement {
>`,
})}
</ha-alert>
</ha-dialog>
</ha-wa-dialog>
`;
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-z-index: 15;
}
h3:first-of-type {
margin-top: 0;
}
.content {
margin-bottom: 24px;
}
.shortcut {
display: flex;
flex-direction: row;
@@ -287,6 +273,10 @@ class DialogShortcuts extends LitElement {
ha-svg-icon {
width: 12px;
}
ha-alert a {
color: var(--primary-color);
}
`,
];
}

View File

@@ -186,7 +186,11 @@ class PanelClimate extends LitElement {
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
transition: box-shadow 200ms linear;
transition:
box-shadow 200ms linear,
width var(--ha-animation-base-duration) ease,
padding-left var(--ha-animation-base-duration) ease,
padding-right var(--ha-animation-base-duration) ease;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
@@ -211,6 +215,11 @@ class PanelClimate extends LitElement {
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
@media (prefers-reduced-motion: reduce) {
.header {
transition: box-shadow 200ms linear;
}
}
.toolbar {
height: var(--header-height);
display: flex;

View File

@@ -21,8 +21,8 @@ import "../../../components/ha-wa-dialog";
import type {
AreaRegistryEntry,
AreaRegistryEntryMutableParams,
} from "../../../data/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area_registry";
} from "../../../data/area/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area/area_registry";
import {
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,

View File

@@ -14,16 +14,16 @@ import "../../../components/ha-button";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import "../../../components/ha-wa-dialog";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
} from "../../../data/area/area_registry";
import { reorderFloorRegistryEntries } from "../../../data/floor_registry";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";

View File

@@ -18,7 +18,7 @@ import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import "../../../components/ha-wa-dialog";
import { updateAreaRegistryEntry } from "../../../data/area_registry";
import { updateAreaRegistryEntry } from "../../../data/area/area_registry";
import type {
FloorRegistryEntry,
FloorRegistryEntryMutableParams,

View File

@@ -23,11 +23,11 @@ import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-tooltip";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
deleteAreaRegistryEntry,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
} from "../../../data/area/area_registry";
import type { AutomationEntity } from "../../../data/automation";
import { fullEntitiesContext } from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";

View File

@@ -31,12 +31,12 @@ import "../../../components/ha-list-item";
import "../../../components/ha-sortable";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
createAreaRegistryEntry,
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
} from "../../../data/area/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import {
createFloorRegistryEntry,

View File

@@ -2,7 +2,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
import type {
AreaRegistryEntry,
AreaRegistryEntryMutableParams,
} from "../../../data/area_registry";
} from "../../../data/area/area_registry";
export interface AreaRegistryDetailDialogParams {
entry?: AreaRegistryEntry;

View File

@@ -58,6 +58,7 @@ import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type {
Action,
DeviceAction,
NonConditionAction,
RepeatAction,
ServiceAction,
@@ -233,6 +234,13 @@ export default class HaAutomationActionRow extends LitElement {
private _renderRow() {
const type = getAutomationActionType(this.action);
const target =
type === "service" && "target" in this.action
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
return html`
${type === "service" && "action" in this.action && this.action.action
? html`
@@ -254,9 +262,7 @@ export default class HaAutomationActionRow extends LitElement {
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}
${type === "service" && "target" in this.action
? this._renderTargets((this.action as ServiceAction).target)
: nothing}
${target ? this._renderTargets(target) : nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -857,6 +863,7 @@ export default class HaAutomationActionRow extends LitElement {
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
ev.stopPropagation();
const action = ev.detail?.item?.value;
if (!action) {

View File

@@ -57,11 +57,11 @@ import {
ACTION_COLLECTIONS,
ACTION_ICONS,
} from "../../../data/action";
import type { FloorComboBoxItem } from "../../../data/area_floor_picker";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../data/area_registry";
} from "../../../data/area/area_registry";
import type { FloorComboBoxItem } from "../../../data/area_floor_picker";
import {
DYNAMIC_PREFIX,
getValueFromDynamic,
@@ -232,6 +232,8 @@ class DialogAddAutomationElement
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _closing = false;
// #endregion variables
// #region lifecycle
@@ -347,6 +349,8 @@ class DialogAddAutomationElement
}
}
this._closing = true;
// if dialog is closed, but root level isn't active, clean up history state
if (mainWindow.history.state?.dialogData) {
this._open = false;
@@ -360,6 +364,7 @@ class DialogAddAutomationElement
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._open = true;
this._closing = false;
this._params = undefined;
this._selectedCollectionIndex = undefined;
this._selectedGroup = undefined;
@@ -431,6 +436,24 @@ class DialogAddAutomationElement
// #region render
private _getEmptyNote(automationElementType: string) {
if (
automationElementType !== "trigger" &&
automationElementType !== "condition"
) {
return undefined;
}
return this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target_note`,
{
labs_link: html`<a href="/config/labs" @click=${this._close}
>${this.hass.localize("ui.panel.config.labs.caption")}</a
>`,
}
);
}
protected render() {
if (!this._params) {
return nothing;
@@ -696,6 +719,7 @@ class DialogAddAutomationElement
.emptyLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
)}
.emptyNote=${this._getEmptyNote(automationElementType)}
.tooltipDescription=${this._tab === "targets"}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
@@ -1691,9 +1715,9 @@ class DialogAddAutomationElement
// #region interaction
private _close() {
private _close = () => {
this._open = false;
}
};
private _back() {
mainWindow.history.back();
@@ -1875,7 +1899,10 @@ class DialogAddAutomationElement
}
private _handleClosed() {
this.closeDialog();
// if closing isn't already in progress, close the dialog
if (!this._closing) {
this.closeDialog();
}
}
// #region interaction

View File

@@ -28,6 +28,10 @@ import "../../../../components/ha-md-list-item";
import "../../../../components/ha-section-title";
import "../../../../components/ha-state-icon";
import "../../../../components/ha-svg-icon";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../../data/area/area_registry";
import {
getAreasNestedInFloors,
type AreaFloorValue,
@@ -35,10 +39,6 @@ import {
type FloorNestedComboBoxItem,
type UnassignedAreasFloorComboBoxItem,
} from "../../../../data/area_floor_picker";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../../data/area_registry";
import {
getConfigEntries,
type ConfigEntry,
@@ -1504,14 +1504,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
box-shadow: inset var(--ha-shadow-offset-x-lg)
calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg)
var(--ha-shadow-spread-lg) var(--ha-color-shadow-light);
}
@media (prefers-color-scheme: dark) {
.targets-show-more {
box-shadow: inset var(--ha-shadow-offset-x-lg)
calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg)
var(--ha-shadow-spread-lg) var(--ha-color-shadow-dark);
}
z-index: 2;
}
@media all and (max-width: 870px), all and (max-height: 500px) {

View File

@@ -1,5 +1,5 @@
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import {
customElement,
eventOptions,
@@ -39,6 +39,8 @@ export class HaAutomationAddItems extends LitElement {
@property({ attribute: "empty-label" }) public emptyLabel!: string;
@property({ attribute: false }) public emptyNote?: string | TemplateResult;
@property({ attribute: false }) public target?: Target;
@property({ attribute: false }) public getLabel!: (
@@ -79,6 +81,9 @@ export class HaAutomationAddItems extends LitElement {
? html`${this.emptyLabel}
${this.target
? html`<div>${this._renderTarget(this.target)}</div>`
: nothing}
${this.emptyNote
? html`<div class="empty-note">${this.emptyNote}</div>`
: nothing}`
: repeat(
this.items,
@@ -199,6 +204,7 @@ export class HaAutomationAddItems extends LitElement {
static styles = css`
:host {
display: flex;
flex-grow: 1;
}
:host([scrollable]) .items {
overflow: auto;
@@ -213,13 +219,24 @@ export class HaAutomationAddItems extends LitElement {
background-color: var(--ha-color-surface-default);
align-items: center;
color: var(--ha-color-text-secondary);
padding: 0;
padding: var(--ha-space-4);
margin: 0 var(--ha-space-4)
max(var(--safe-area-inset-bottom), var(--ha-space-3));
line-height: var(--ha-line-height-expanded);
justify-content: center;
}
.empty-note {
color: var(--ha-color-text-secondary);
margin-top: var(--ha-space-2);
text-align: center;
}
.empty-note a {
color: currentColor;
text-decoration: underline;
}
.items.error {
background-color: var(--ha-color-fill-danger-quiet-resting);
color: var(--ha-color-on-danger-normal);

View File

@@ -76,6 +76,7 @@ import "./types/ha-automation-condition-template";
import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import type { DeviceCondition } from "../../../../data/device/device_automation";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -184,6 +185,14 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const target =
"target" in (this.conditionDescriptions[this.condition.condition] || {})
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
return html`
<ha-condition-icon
slot="leading-icon"
@@ -194,10 +203,7 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${"target" in
(this.conditionDescriptions[this.condition.condition] || {})
? this._renderTargets((this.condition as PlatformCondition).target)
: nothing}
${target ? this._renderTargets(target) : nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -833,6 +839,7 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
ev.stopPropagation();
const action = ev.detail?.item?.value;
if (!action) {

View File

@@ -67,7 +67,7 @@ import type { HaMdMenuItem } from "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
import type { AutomationEntity } from "../../../data/automation";
import {
deleteAutomation,
@@ -381,12 +381,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.automation.picker.headers.voice_assistants"
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "icon",
type: "flex",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
minWidth: "160px",
maxWidth: "160px",
template: (automation) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -350,6 +350,7 @@ export default class HaAutomationOptionRow extends LitElement {
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
ev.stopPropagation();
const action = ev.detail?.item?.value;
if (!action) {

View File

@@ -56,6 +56,7 @@ import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
@@ -196,6 +197,15 @@ export default class HaAutomationTriggerRow extends LitElement {
const yamlMode = this._yamlMode || !supported;
const target =
type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
return html`
${type === "list"
? html`<ha-svg-icon
@@ -210,11 +220,7 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? this._renderTargets((this.trigger as PlatformTrigger).target)
: nothing}
${target ? this._renderTargets(target) : nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -809,6 +815,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
ev.stopPropagation();
const action = ev.detail?.item?.value;
if (!action) {

View File

@@ -152,7 +152,6 @@ class HaBackupConfigAgents extends LitElement {
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -66,7 +66,6 @@ class HaBackupAgentsPicker extends LitElement {
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -226,7 +226,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"

View File

@@ -207,7 +207,6 @@ class HaConfigBackupDetails extends LitElement {
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized:
this.hass.themes?.darkMode,
})}

View File

@@ -252,7 +252,6 @@ class HaConfigBackupSettings extends LitElement {
.src=${brandsUrl({
domain: "cloud",
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -4,10 +4,8 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-dialog-footer";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -28,14 +26,21 @@ class DialogAssignCategory extends LitElement {
@state() private _submitting?: boolean;
@state() private _open = false;
public showDialog(params: AssignCategoryDialogParams): void {
this._params = params;
this._scope = params.scope;
this._category = params.entityReg.categories[params.scope];
this._error = undefined;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -47,47 +52,46 @@ class DialogAssignCategory extends LitElement {
}
const entry = this._params.entityReg.categories[this._params.scope];
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
entry
? this.hass.localize("ui.panel.config.category.assign.edit")
: this.hass.localize("ui.panel.config.category.assign.assign")
)}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${entry
? this.hass.localize("ui.panel.config.category.assign.edit")
: this.hass.localize("ui.panel.config.category.assign.assign")}
@closed=${this._dialogClosed}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-category-picker
.hass=${this.hass}
.scope=${this._scope}
.label=${this.hass.localize(
"ui.components.category-picker.category"
)}
.value=${this._category}
@value-changed=${this._categoryChanged}
></ha-category-picker>
</div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-category-picker
.hass=${this.hass}
.scope=${this._scope}
.label=${this.hass.localize(
"ui.components.category-picker.category"
)}
.value=${this._category}
@value-changed=${this._categoryChanged}
autofocus
></ha-category-picker>
</div>
<ha-button
appearance="plain"
slot="primaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!!this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!!this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}

View File

@@ -3,9 +3,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-icon-picker";
import "../../../components/ha-settings-row";
import "../../../components/ha-button";
import "../../../components/ha-textfield";
import type {
@@ -30,11 +30,14 @@ class DialogCategoryDetail extends LitElement {
@state() private _submitting?: boolean;
@state() private _open = false;
public async showDialog(
params: CategoryRegistryDetailDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
this._open = true;
if (this._params.entry) {
this._name = this._params.entry.name || "";
this._icon = this._params.entry.icon || null;
@@ -46,6 +49,10 @@ class DialogCategoryDetail extends LitElement {
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -58,61 +65,55 @@ class DialogCategoryDetail extends LitElement {
const entry = this._params.entry;
const nameInvalid = !this._isNameValid();
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
entry
? this.hass.localize("ui.panel.config.category.editor.edit")
: this.hass.localize("ui.panel.config.category.editor.create")
)}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${entry
? this.hass.localize("ui.panel.config.category.editor.edit")
: this.hass.localize("ui.panel.config.category.editor.create")}
@closed=${this._dialogClosed}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-textfield
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass.localize(
"ui.panel.config.category.editor.name"
)}
.validationMessage=${this.hass.localize(
"ui.panel.config.category.editor.required_error_msg"
)}
required
dialogInitialFocus
></ha-textfield>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-textfield
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass.localize("ui.panel.config.category.editor.name")}
.validationMessage=${this.hass.localize(
"ui.panel.config.category.editor.required_error_msg"
)}
required
autofocus
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize(
"ui.panel.config.category.editor.icon"
)}
></ha-icon-picker>
</div>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.category.editor.icon")}
></ha-icon-picker>
</div>
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.add")}
</ha-button>
</ha-dialog>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.add")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}

View File

@@ -20,7 +20,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_CATEGORIES_ID = "___NO_CATEGORIES___";
@customElement("ha-category-picker")
export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@@ -101,17 +100,11 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
);
private _getCategories = memoizeOne(
(categories: CategoryRegistryEntry[] | undefined): PickerComboBoxItem[] => {
if (!categories || categories.length === 0) {
return [
{
id: NO_CATEGORIES_ID,
primary: this.hass.localize(
"ui.components.category-picker.no_categories"
),
icon_path: mdiTag,
},
];
(
categories: CategoryRegistryEntry[] | undefined
): PickerComboBoxItem[] | undefined => {
if (!categories) {
return undefined;
}
const items = categories.map<PickerComboBoxItem>((category) => ({
@@ -210,10 +203,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_CATEGORIES_ID) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -33,10 +33,7 @@ import {
checkForEntityUpdates,
filterUpdateEntitiesParameterized,
} from "../../../data/update";
import {
QuickBarMode,
showQuickBar,
} from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@@ -375,7 +372,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
};
showQuickBar(this, {
mode: QuickBarMode.Command,
hint: this.hass.enableShortcuts
? this.hass.localize("ui.dialogs.quick-bar.key_c_tip", params)
: undefined,

View File

@@ -51,7 +51,7 @@ export class HaDeviceInfoMatter extends SubscribeMixin(LitElement) {
return html`
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.matter.device_info.device_info"
"ui.panel.config.matter.device_info.matter_info"
)}
>
<div class="row">

View File

@@ -54,7 +54,7 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getSubEntries, sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";

View File

@@ -498,12 +498,12 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.entities.picker.headers.voice_assistants"
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "icon",
type: "flex",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
minWidth: "160px",
maxWidth: "160px",
template: (entry) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entities,

View File

@@ -227,7 +227,6 @@ export class DialogHelperDetail extends LitElement {
src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -487,12 +487,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.helpers.picker.headers.voice_assistants"
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "icon",
type: "flex",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
minWidth: "160px",
maxWidth: "160px",
template: (helper) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -60,7 +60,6 @@ class HaDomainIntegrations extends LitElement {
src=${brandsUrl({
domain: flow.handler,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
@@ -106,7 +105,6 @@ class HaDomainIntegrations extends LitElement {
src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
@@ -170,7 +168,6 @@ class HaDomainIntegrations extends LitElement {
src=${brandsUrl({
domain: this.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -57,7 +57,6 @@ export class HaIntegrationListItem extends ListItemBase {
src=${brandsUrl({
domain: this.integration.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
brand: this.brand,
})}

View File

@@ -23,23 +23,12 @@ import {
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { bluetoothTabs } from "./bluetooth-config-dashboard";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
export const bluetoothAdvertisementMonitorTabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.bluetooth.advertisement_monitor",
path: "advertisement-monitor",
},
{
translationKey: "ui.panel.config.bluetooth.visualization",
path: "visualization",
},
];
@customElement("bluetooth-advertisement-monitor")
export class BluetoothAdvertisementMonitorPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -232,7 +221,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
filter=${this.address || ""}
clickable
.tabs=${bluetoothAdvertisementMonitorTabs}
.tabs=${bluetoothTabs}
></hass-tabs-subpage-data-table>
`;
}

View File

@@ -1,4 +1,10 @@
import { mdiCogOutline } from "@mdi/js";
import {
mdiBroadcast,
mdiCogOutline,
mdiLan,
mdiLinkVariant,
mdiNetwork,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -8,7 +14,6 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import type {
BluetoothAllocationsData,
BluetoothScannerState,
@@ -24,16 +29,44 @@ import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import "../../../../../layouts/hass-subpage";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { HomeAssistant, Route } from "../../../../../types";
export const bluetoothTabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.bluetooth.tabs.overview",
path: `/config/bluetooth/dashboard`,
iconPath: mdiNetwork,
},
{
translationKey: "ui.panel.config.bluetooth.tabs.advertisements",
path: `/config/bluetooth/advertisement-monitor`,
iconPath: mdiBroadcast,
},
{
translationKey: "ui.panel.config.bluetooth.tabs.visualization",
path: `/config/bluetooth/visualization`,
iconPath: mdiLan,
},
{
translationKey: "ui.panel.config.bluetooth.tabs.connections",
path: `/config/bluetooth/connection-monitor`,
iconPath: mdiLinkVariant,
},
];
@customElement("bluetooth-config-dashboard")
export class BluetoothConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _configEntries: ConfigEntry[] = [];
@state() private _connectionAllocationData: BluetoothAllocationsData[] = [];
@@ -122,10 +155,12 @@ export class BluetoothConfigDashboard extends LitElement {
protected render(): TemplateResult {
return html`
<hass-subpage
<hass-tabs-subpage
header=${this.hass.localize("ui.panel.config.bluetooth.title")}
.narrow=${this.narrow}
.hass=${this.hass}
.route=${this.route}
.tabs=${bluetoothTabs}
>
<div class="content">
<ha-card
@@ -135,60 +170,8 @@ export class BluetoothConfigDashboard extends LitElement {
>
<ha-list>${this._renderAdaptersList()}</ha-list>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor_details"
)}
</p>
</div>
<div class="card-actions">
<ha-button
href="/config/bluetooth/advertisement-monitor"
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
</ha-button>
<ha-button
href="/config/bluetooth/visualization"
appearance="plain"
>
${this.hass.localize("ui.panel.config.bluetooth.visualization")}
</ha-button>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor_description"
)}
</p>
</div>
<div class="card-actions">
<ha-button
href="/config/bluetooth/connection-monitor"
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_monitor"
)}
</ha-button>
</div>
</ha-card>
</div>
</hass-subpage>
</hass-tabs-subpage>
`;
}

View File

@@ -24,6 +24,7 @@ import type { DeviceRegistryEntry } from "../../../../../data/device/device_regi
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { bluetoothTabs } from "./bluetooth-config-dashboard";
@customElement("bluetooth-connection-monitor")
export class BluetoothConnectionMonitorPanel extends LitElement {
@@ -214,6 +215,7 @@ export class BluetoothConnectionMonitorPanel extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${bluetoothTabs}
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
.initialGroupColumn=${this._activeGrouping}

View File

@@ -26,9 +26,9 @@ import {
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import "../../../../../layouts/hass-subpage";
import "../../../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import { bluetoothAdvertisementMonitorTabs } from "./bluetooth-advertisement-monitor";
import { bluetoothTabs } from "./bluetooth-config-dashboard";
const UPDATE_THROTTLE_TIME = 10000;
@@ -123,8 +123,7 @@ export class BluetoothNetworkVisualization extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
header=${this.hass.localize("ui.panel.config.bluetooth.visualization")}
.tabs=${bluetoothAdvertisementMonitorTabs}
.tabs=${bluetoothTabs}
>
<ha-network-graph
.hass=${this.hass}

View File

@@ -203,7 +203,6 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
src=${brandsUrl({
domain: preview_feature.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -175,24 +175,31 @@ export class HaConfigLovelaceDashboards extends LitElement {
template: narrow
? undefined
: (dashboard) => html`
${dashboard.title}
${dashboard.default
? html`
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="padding-left: 10px; padding-inline-start: 10px; padding-inline-end: initial; direction: var(--direction);"
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
placement="right"
>
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.default_dashboard`
)}
</ha-tooltip>
`
: nothing}
<span
style="display:flex; align-items:center; gap: var(--ha-space-2); min-width:0; width:100%;"
>
<span
style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;"
>${dashboard.title}</span
>
${dashboard.default
? html`
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="flex-shrink:0;"
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
placement="right"
>
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.default_dashboard`
)}
</ha-tooltip>
`
: nothing}
</span>
`,
},
};

View File

@@ -75,7 +75,6 @@ class HaConfigRepairs extends LitElement {
src=${brandsUrl({
domain: issue.issue_domain || issue.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
.title=${domainName}

View File

@@ -58,7 +58,6 @@ class IntegrationsStartupTime extends LitElement {
src=${brandsUrl({
domain: setup.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -61,7 +61,7 @@ import "../../../components/ha-state-icon";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
import type { CategoryRegistryEntry } from "../../../data/category_registry";
import {
createCategoryRegistryEntry,
@@ -415,12 +415,12 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.scene.picker.headers.voice_assistants"
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "icon",
type: "flex",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
minWidth: "160px",
maxWidth: "160px",
template: (scene) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
@@ -1189,13 +1189,19 @@ ${rejected
private async _duplicate(scene) {
if (scene.attributes.id) {
const config = await getSceneConfig(this.hass, scene.attributes.id);
showSceneEditor({
...config,
id: undefined,
name: `${config?.name} (${this.hass.localize(
"ui.panel.config.scene.picker.duplicate"
)})`,
});
const entityRegEntry = this._entityReg.find(
(reg) => reg.entity_id === scene.entity_id
);
showSceneEditor(
{
...config,
id: undefined,
name: `${config?.name} (${this.hass.localize(
"ui.panel.config.scene.picker.duplicate"
)})`,
},
entityRegEntry?.area_id || undefined
);
}
}

View File

@@ -62,7 +62,7 @@ import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
import type { CategoryRegistryEntry } from "../../../data/category_registry";
import {
createCategoryRegistryEntry,
@@ -403,12 +403,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
},
voice_assistants: {
title: localize(
"ui.panel.config.script.picker.headers.voice_assistants"
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "icon",
type: "flex",
defaultHidden: true,
minWidth: "100px",
maxWidth: "100px",
minWidth: "160px",
maxWidth: "160px",
template: (script) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -8,6 +8,8 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-check-list-item";
import "../../../components/search-input";
import "../../../components/ha-dialog";
@@ -18,10 +20,16 @@ import "../../../components/ha-list";
import type { ExposeEntitySettings } from "../../../data/expose";
import { voiceAssistants } from "../../../data/expose";
import { haStyle } from "../../../resources/styles";
import { loadVirtualizer } from "../../../resources/virtualizer";
import type { HomeAssistant } from "../../../types";
import "./entity-voice-settings";
import type { ExposeEntityDialogParams } from "./show-dialog-expose-entity";
interface FilteredEntity {
entity: HassEntity;
nameList: (string | undefined)[];
}
@customElement("dialog-expose-entity")
class DialogExposeEntity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -32,6 +40,12 @@ class DialogExposeEntity extends LitElement {
@state() private _selected: string[] = [];
public willUpdate(): void {
if (!this.hasUpdated) {
loadVirtualizer();
}
}
public async showDialog(params: ExposeEntityDialogParams): Promise<void> {
this._params = params;
}
@@ -141,38 +155,101 @@ class DialogExposeEntity extends LitElement {
(
exposedEntities: Record<string, ExposeEntitySettings>,
filter?: string
) => {
): FilteredEntity[] => {
const lowerFilter = filter?.toLowerCase();
return Object.values(this.hass.states).filter(
(entity) =>
this._params!.filterAssistants.some(
(ass) => !exposedEntities[entity.entity_id]?.[ass]
) &&
(!lowerFilter ||
entity.entity_id.toLowerCase().includes(lowerFilter) ||
computeStateName(entity)?.toLowerCase().includes(lowerFilter))
);
const result: FilteredEntity[] = [];
for (const entity of Object.values(this.hass.states)) {
if (
this._params!.filterAssistants.every(
(ass) => exposedEntities[entity.entity_id]?.[ass]
)
) {
continue;
}
const nameList = computeEntityNameList(
entity,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
if (!lowerFilter) {
result.push({ entity, nameList });
continue;
}
if (entity.entity_id.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
const entityName = computeStateName(entity);
if (entityName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
const [, deviceName, areaName] = nameList;
if (deviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (areaName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
}
return result;
}
);
private _renderItem = (entityState: HassEntity) => html`
<ha-check-list-item
graphic="icon"
twoLine
.value=${entityState.entity_id}
.selected=${this._selected.includes(entityState.entity_id)}
@request-selected=${this._handleSelected}
>
<ha-state-icon
title=${ifDefined(entityState?.state)}
slot="graphic"
.hass=${this.hass}
.stateObj=${entityState}
></ha-state-icon>
${computeStateName(entityState)}
<span slot="secondary">${entityState.entity_id}</span>
</ha-check-list-item>
`;
private _renderItem = (item: FilteredEntity) => {
const { entity: entityState, nameList } = item;
const [entityName, deviceName, areaName] = nameList;
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || entityState.entity_id;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-check-list-item
graphic="icon"
?twoLine=${context}
?threeLine=${showEntityId}
.value=${entityState.entity_id}
.selected=${this._selected.includes(entityState.entity_id)}
@request-selected=${this._handleSelected}
>
<ha-state-icon
title=${ifDefined(entityState?.state)}
slot="graphic"
.hass=${this.hass}
.stateObj=${entityState}
></ha-state-icon>
${primary}
${context || showEntityId
? html`<span slot="secondary">
${context}
${showEntityId
? html`<br /><span class="entity-id"
>${entityState.entity_id}</span
>`
: nothing}
</span>`
: nothing}
</ha-check-list-item>
`;
};
private _expose() {
this._params!.exposeEntities(this._selected);
@@ -198,6 +275,7 @@ class DialogExposeEntity extends LitElement {
width: 100%;
display: block;
box-sizing: border-box;
margin-top: var(--ha-space-2);
--text-field-suffix-padding-left: 8px;
}
.header {
@@ -210,7 +288,7 @@ class DialogExposeEntity extends LitElement {
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: -4px 0;
margin: calc(var(--ha-space-1) * -1) 0;
}
.subtitle {
color: var(--secondary-text-color);
@@ -225,9 +303,17 @@ class DialogExposeEntity extends LitElement {
width: 100%;
height: 72px;
}
ha-check-list-item[threeLine] {
height: 88px;
}
ha-check-list-item .entity-id {
line-height: var(--ha-line-height-normal);
padding-left: var(--ha-space-1);
font-size: var(--ha-font-size-xs);
}
ha-check-list-item ha-state-icon {
margin-left: 24px;
margin-inline-start: 24px;
margin-left: var(--ha-space-6);
margin-inline-start: var(--ha-space-6);
margin-inline-end: initial;
}
@media all and (max-height: 800px) {
@@ -262,8 +348,8 @@ class DialogExposeEntity extends LitElement {
--text-field-suffix-padding-left: unset;
}
ha-check-list-item ha-state-icon {
margin-left: 8px;
margin-inline-start: 8px;
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-inline-end: initial;
}
}

View File

@@ -167,20 +167,21 @@ export class VoiceAssistantsExpose extends LitElement {
filterable: true,
direction: "asc",
flex: 2,
template: narrow
? undefined
: (entry) => html`
${entry.name}<br />
<div class="secondary">${entry.entity_id}</div>
`,
},
// For search & narrow
area: {
title: localize("ui.panel.config.voice_assistants.expose.headers.area"),
sortable: true,
groupable: true,
filterable: true,
template: (entry) => entry.area || "—",
},
entity_id: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.entity_id"
),
hidden: !narrow,
sortable: true,
filterable: true,
defaultHidden: true,
},
domain: {
title: localize(
@@ -191,13 +192,6 @@ export class VoiceAssistantsExpose extends LitElement {
filterable: true,
groupable: true,
},
area: {
title: localize("ui.panel.config.voice_assistants.expose.headers.area"),
sortable: true,
groupable: true,
filterable: true,
template: (entry) => entry.area || "—",
},
assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
@@ -819,8 +813,8 @@ export class VoiceAssistantsExpose extends LitElement {
}
.selected-txt {
font-weight: var(--ha-font-weight-bold);
padding-left: 16px;
padding-inline-start: 16px;
padding-left: var(--ha-space-4);
padding-inline-start: var(--ha-space-4);
direction: var(--direction);
}
.table-header .selected-txt {
@@ -830,8 +824,8 @@ export class VoiceAssistantsExpose extends LitElement {
font-size: var(--ha-font-size-l);
}
.header-toolbar .header-btns {
margin-right: -12px;
margin-inline-end: -12px;
margin-right: calc(var(--ha-space-3) * -1);
margin-inline-end: calc(var(--ha-space-3) * -1);
direction: var(--direction);
}
.header-btns {
@@ -839,17 +833,17 @@ export class VoiceAssistantsExpose extends LitElement {
}
.header-btns > ha-button,
.header-btns > ha-icon-button {
margin: 8px;
margin: var(--ha-space-2);
}
ha-button-menu {
margin-left: 8px;
margin-inline-start: 8px;
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-inline-end: initial;
}
.clear {
color: var(--primary-color);
padding-left: 8px;
padding-inline-start: 8px;
padding-left: var(--ha-space-2);
padding-inline-start: var(--ha-space-2);
text-transform: uppercase;
direction: var(--direction);
}

View File

@@ -160,6 +160,10 @@ class PanelDeveloperTools extends LitElement {
border-bottom: var(--app-header-border-bottom, none);
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
transition:
width var(--ha-animation-base-duration) ease,
padding-left var(--ha-animation-base-duration) ease,
padding-right var(--ha-animation-base-duration) ease;
}
:host([narrow]) .header {
width: calc(
@@ -170,6 +174,11 @@ class PanelDeveloperTools extends LitElement {
);
padding-left: var(--safe-area-inset-left);
}
@media (prefers-reduced-motion: reduce) {
.header {
transition: none;
}
}
.toolbar {
height: var(--header-height);

View File

@@ -186,7 +186,11 @@ class PanelLight extends LitElement {
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
transition: box-shadow 200ms linear;
transition:
box-shadow 200ms linear,
width var(--ha-animation-base-duration) ease,
padding-left var(--ha-animation-base-duration) ease,
padding-right var(--ha-animation-base-duration) ease;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
@@ -211,6 +215,11 @@ class PanelLight extends LitElement {
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
@media (prefers-reduced-motion: reduce) {
.header {
transition: box-shadow 200ms linear;
}
}
.toolbar {
height: var(--header-height);
display: flex;

View File

@@ -160,7 +160,6 @@ class HaLogbookRenderer extends LitElement {
? brandsUrl({
domain: domain!,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})
: undefined;

View File

@@ -16,7 +16,7 @@ import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-domain-icon";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import { forwardHaptic } from "../../../data/haptics";
import { computeCssVariable } from "../../../resources/css-variables";
import type { HomeAssistant } from "../../../types";

View File

@@ -2,8 +2,6 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
@@ -28,11 +26,6 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const INTERVAL = 1000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -47,49 +40,6 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
@state() private _year = "";
@state() private _month = "";
@state() private _day = "";
private _tickInterval?: undefined | number;
private _currentDate = new Date();
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeDateTime();
if (this.config?.date && this.config.date !== "none") {
this._startTick();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
this._stopTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeDateTime();
}
};
private _initDate() {
if (!this.config || !this.hass) {
return;
@@ -101,27 +51,6 @@ export class HuiClockCardAnalog extends LitElement {
}
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
...(this.config.date && this.config.date !== "none"
? this.config.date === "day"
? {
day: "numeric",
}
: this.config.date === "day-month"
? {
month: "short",
day: "numeric",
}
: this.config.date === "day-month-long"
? {
month: "long",
day: "numeric",
}
: {
year: "numeric",
month: this.config.date === "long" ? "long" : "short",
day: "numeric",
}
: {}),
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
@@ -131,31 +60,42 @@ export class HuiClockCardAnalog extends LitElement {
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeDateTime();
this._computeOffsets();
}
private _startTick() {
this._tick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
}
private _stopTick() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = undefined;
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _updateDate() {
this._currentDate = new Date();
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
}
private _computeDateTime() {
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
}
};
private _computeOffsets() {
if (!this._dateTimeFormat) return;
this._updateDate();
const parts = this._dateTimeFormat.formatToParts(this._currentDate);
const parts = this._dateTimeFormat.formatToParts();
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -163,7 +103,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = this._currentDate.getMilliseconds();
const ms = new Date().getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -171,38 +111,18 @@ export class HuiClockCardAnalog extends LitElement {
this._secondOffsetSec = secondsWithMs;
this._minuteOffsetSec = minute * 60 + secondsWithMs;
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
// Also update date parts if date is shown
if (this.config?.date && this.config.date !== "none") {
this._year = parts.find((p) => p.type === "year")?.value ?? "";
this._month = parts.find((p) => p.type === "month")?.value ?? "";
this._day = parts.find((p) => p.type === "day")?.value ?? "";
}
}
private _tick() {
this._computeDateTime();
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const isLongDate =
config.date === "day-month-long" || config.date === "long";
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate } =
this._computeClock(this.config);
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const indicator = (number?: number) => html`
<div
@@ -243,14 +163,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? QUARTER_TICKS.map(
? Array.from({ length: 4 }, (_, i) => i).map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
style=${`--tick-rotation: ${i * 90}deg;`}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -258,30 +178,28 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? HOUR_TICKS.map(
? Array.from({ length: 12 }, (_, i) => i).map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
style=${`--tick-rotation: ${i * 30}deg;`}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? MINUTE_TICKS.map(
? Array.from({ length: 60 }, (_, i) => i).map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
style=${`--tick-rotation: ${i * 6}deg;`}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -290,33 +208,14 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${this.config?.date && this.config.date !== "none"
? html`<div
class=${classMap({
"date-parts": true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
<span class="date-part day-month"
>${this._day} ${this._month}</span
>
<span class="date-part year">${this._year}</span>
</div>`
: nothing}
<div class="center-dot"></div>
<div
class="hand hour"
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
></div>
<div
class="hand minute"
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
></div>
${this.config.show_seconds
? html`<div
@@ -325,13 +224,11 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
></div>`
: nothing}
</div>
@@ -373,14 +270,6 @@ export class HuiClockCardAnalog extends LitElement {
box-sizing: border-box;
}
/* Modern browsers: Use container queries for responsive font sizing */
@supports (container-type: inline-size) {
.dial {
container-type: inline-size;
container-name: clock;
}
}
.dial-border {
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-circle);
@@ -518,78 +407,6 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date-parts {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: grid;
align-items: center;
grid-template-areas:
"day-month"
"year";
direction: ltr;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
max-width: 87%;
overflow: hidden;
white-space: nowrap;
}
/* Modern browsers: Use container queries for responsive font sizing */
@supports (container-type: inline-size) {
/* Small clock with long date: reduce to xs */
@container clock (max-width: 139px) {
.date-parts.long-date {
font-size: var(--ha-font-size-xs);
}
}
/* Medium clock: scale up */
@container clock (min-width: 140px) {
.date-parts {
font-size: var(--ha-font-size-l);
}
}
/* Large clock: scale up more */
@container clock (min-width: 200px) {
.date-parts {
font-size: var(--ha-font-size-xl);
}
}
}
/* Legacy browsers: Use existing size classes */
@supports not (container-type: inline-size) {
/* Small clock (no size class) with long date */
.date-parts.long-date:not(.size-medium):not(.size-large) {
font-size: var(--ha-font-size-xs);
}
.date-parts.size-medium {
font-size: var(--ha-font-size-l);
}
.date-parts.size-large {
font-size: var(--ha-font-size-xl);
}
}
.date-part.day-month {
grid-area: day-month;
overflow: hidden;
}
.date-part.year {
grid-area: year;
overflow: hidden;
}
`;
}

View File

@@ -24,8 +24,6 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _initDate() {
@@ -41,27 +39,6 @@ export class HuiClockCardDigital extends LitElement {
const h12 = useAmPm(locale);
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
...(this.config.date && this.config.date !== "none"
? this.config.date === "day"
? {
day: "numeric",
}
: this.config.date === "day-month"
? {
month: "short",
day: "numeric",
}
: this.config.date === "day-month-long"
? {
month: "long",
day: "numeric",
}
: {
year: "numeric",
month: this.config.date === "long" ? "long" : "short",
day: "numeric",
}
: {}),
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
@@ -116,16 +93,6 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._date = this.config?.date
? [
parts.find((part) => part.type === "day")?.value,
parts.find((part) => part.type === "month")?.value,
parts.find((part) => part.type === "year")?.value,
]
.filter(Boolean)
.join(" ")
: undefined;
}
render() {
@@ -146,9 +113,6 @@ export class HuiClockCardDigital extends LitElement {
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
${this.config.date && this.config.date !== "none"
? html`<div class="date ${sizeClass}">${this._date}</div>`
: nothing}
`;
}
@@ -224,20 +188,6 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}

View File

@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatPowerShort,
@@ -326,6 +327,7 @@ class HuiPowerSankeyCard
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: device.stat_rate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
@@ -461,6 +463,7 @@ class HuiPowerSankeyCard
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
: html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data"
@@ -475,6 +478,13 @@ class HuiPowerSankeyCard
${formatPowerShort(this.hass, value)}
</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
/**
* Compute real-time power data from current entity states.
* Similar to computeConsumptionData but for instantaneous power.

View File

@@ -73,13 +73,18 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
};
// If the element has fixed rows or columns, we use the values from the element
// unless the user has already configured their own
if (elementOptions.fixed_rows) {
mergedConfig.rows = elementOptions.rows;
if (configOptions.rows === undefined) {
mergedConfig.rows = elementOptions.rows;
}
delete mergedConfig.min_rows;
delete mergedConfig.max_rows;
}
if (elementOptions.fixed_columns) {
mergedConfig.columns = elementOptions.columns;
if (configOptions.columns === undefined) {
mergedConfig.columns = elementOptions.columns;
}
delete mergedConfig.min_columns;
delete mergedConfig.max_columns;
}

View File

@@ -11,7 +11,10 @@ import { findEntities } from "../common/find-entities";
import type { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element";
import type { PictureElementsCardConfig } from "./types";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "./types";
import type { PersonEntity } from "../../../data/person";
@customElement("hui-picture-elements-card")
@@ -166,6 +169,7 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
.aspectRatio=${this._config.aspect_ratio}
.darkModeFilter=${this._config.dark_mode_filter}
.darkModeImage=${darkModeImage}
@click=${this._handleImageClick}
></hui-image>
${this._elements}
</div>
@@ -221,6 +225,19 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
curCardEl === elToReplace ? newCardEl : curCardEl
);
}
private _handleImageClick(ev: MouseEvent): void {
if (!this.preview || !this._config?.[PREVIEW_CLICK_CALLBACK]) {
return;
}
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 100;
const y = ((ev.clientY - rect.top) / rect.height) * 100;
// only the edited card has this callback
this._config[PREVIEW_CLICK_CALLBACK](x, y);
}
}
declare global {

View File

@@ -421,7 +421,6 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date?: "none" | "short" | "long" | "day" | "day-month" | "day-month-long";
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
@@ -488,6 +487,10 @@ export interface PictureCardConfig extends LovelaceCardConfig {
alt_text?: string;
}
// Symbol for preview click callback - preserved through spreads, not serialized
// This allows the editor to attach a callback that only exists on the edited card's config
export const PREVIEW_CLICK_CALLBACK = Symbol("previewClickCallback");
export interface PictureElementsCardConfig extends LovelaceCardConfig {
title?: string;
image?: string | MediaSelectorValue;
@@ -502,6 +505,7 @@ export interface PictureElementsCardConfig extends LovelaceCardConfig {
theme?: string;
dark_mode_image?: string | MediaSelectorValue;
dark_mode_filter?: string;
[PREVIEW_CLICK_CALLBACK]?: (x: number, y: number) => void;
}
export interface PictureEntityCardConfig extends LovelaceCardConfig {

View File

@@ -39,19 +39,6 @@ const cardConfigStruct = assign(
time_zone: optional(enums(Object.keys(timezones))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date: optional(
defaulted(
union([
literal("none"),
literal("short"),
literal("long"),
literal("day"),
literal("day-month"),
literal("day-month-long"),
]),
literal("none")
)
),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
@@ -106,7 +93,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "box",
mode: "dropdown",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -132,27 +119,6 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date",
selector: {
select: {
mode: "dropdown",
options: [
"none",
"short",
"long",
"day",
"day-month",
"day-month-long",
].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.dates.${value}`
),
})),
},
},
},
...(clockStyle === "digital"
? ([
{
@@ -294,14 +260,13 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config: ClockCardConfig) => ({
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_zone: "auto",
time_format: "auto",
show_seconds: false,
no_background: false,
date: "none",
// Analog clock options
border: false,
ticks: "hour",
@@ -325,9 +290,8 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -403,10 +367,6 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -432,10 +392,6 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`

View File

@@ -15,12 +15,16 @@ import {
} from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import type { HomeAssistant } from "../../../../types";
import type { PictureElementsCardConfig } from "../../cards/types";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
@@ -28,7 +32,6 @@ import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style";
import "../hui-picture-elements-card-row-editor";
import type { LovelaceElementConfig } from "../../elements/types";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const genericElementConfigStruct = type({
@@ -66,6 +69,44 @@ export class HuiPictureElementsCardEditor
this._config = config;
}
private _onPreviewClick = (x: number, y: number): void => {
if (this._subElementEditorConfig?.type === "element") {
this._handlePositionClick(x, y);
}
};
private _handlePositionClick(x: number, y: number): void {
if (
!this._subElementEditorConfig?.elementConfig ||
this._subElementEditorConfig.type !== "element" ||
this._subElementEditorConfig.elementConfig.type === "conditional"
) {
return;
}
const elementConfig = this._subElementEditorConfig
.elementConfig as LovelaceElementConfig;
const currentPosition = (elementConfig.style as Record<string, string>)
?.position;
if (currentPosition && currentPosition !== "absolute") {
return;
}
const newElement = {
...elementConfig,
style: {
...((elementConfig.style as Record<string, string>) || {}),
left: `${Math.round(x)}%`,
top: `${Math.round(y)}%`,
},
};
const updateEvent = new CustomEvent("config-changed", {
detail: { config: newElement },
});
this._handleSubElementChanged(updateEvent);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
@@ -138,6 +179,16 @@ export class HuiPictureElementsCardEditor
if (this._subElementEditorConfig) {
return html`
${this._subElementEditorConfig.type === "element" &&
this._subElementEditorConfig.elementConfig?.type !== "conditional"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.editor.card.picture-elements.position_hint"
)}
</ha-alert>
`
: nothing}
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
@@ -181,6 +232,7 @@ export class HuiPictureElementsCardEditor
return;
}
// no need to attach the preview click callback here, no element is being edited
fireEvent(this, "config-changed", { config: ev.detail.value });
}
@@ -191,7 +243,8 @@ export class HuiPictureElementsCardEditor
const config = {
...this._config,
elements: ev.detail.elements as LovelaceElementConfig[],
} as LovelaceCardConfig;
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
} as PictureElementsCardConfig;
fireEvent(this, "config-changed", { config });
@@ -232,7 +285,12 @@ export class HuiPictureElementsCardEditor
elementConfig: value,
};
fireEvent(this, "config-changed", { config: this._config });
fireEvent(this, "config-changed", {
config: {
...this._config,
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
},
});
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {

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