Compare commits

..

12 Commits

Author SHA1 Message Date
Aidan Timson
4e6fb3c103 Scale small based on date length 2026-01-13 10:52:35 +00:00
Aidan Timson
4b8cf9a056 Sizing (CSS Impl) 2026-01-13 10:40:36 +00:00
Aidan Timson
bcac688538 Sizing (JS Impl) 2026-01-13 10:40:09 +00:00
Aidan Timson
616209c0f0 Format 2026-01-13 08:57:33 +00:00
Aidan Timson
21ba3952d9 Add 2026-01-13 08:57:33 +00:00
Aidan Timson
7392e05230 Improve 2026-01-13 08:57:33 +00:00
Aidan Timson
74de8365eb Type 2026-01-13 08:57:33 +00:00
Aidan Timson
999d54147d Setup 2026-01-13 08:57:33 +00:00
Aidan Timson
721d0237ac Match 2026-01-13 08:57:33 +00:00
Aidan Timson
a3ad223230 Setup analog clock 2026-01-13 08:57:33 +00:00
Aidan Timson
69290a2ffb Add date to digital clock 2026-01-13 08:57:33 +00:00
Aidan Timson
c840af63f5 Setup 2026-01-13 08:57:33 +00:00
127 changed files with 2412 additions and 2880 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.13.0
24.12.0

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { AreaRegistryEntry } from "../../../src/data/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/area_registry";
import type { AreaRegistryEntry } from "../../../../src/data/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/area_registry";
import type { AreaRegistryEntry } from "../../../../src/data/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,3 +1,4 @@
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.17",
"@vitest/coverage-v8": "4.0.16",
"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.17",
"vitest": "4.0.16",
"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.13.0"
"node": "24.12.0"
}
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { AreaRegistryEntry } from "../../../data/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/area_registry";
import type { AreaRegistryEntry } from "../../../data/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/area_registry";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type {
EntityRegistryDisplayEntry,

View File

@@ -1,16 +1,6 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
export const deepEqual = (a: any, b: any): boolean => {
if (a === b) {
return true;
}
@@ -28,7 +18,7 @@ export const deepEqual = (
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i], options)) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
@@ -45,7 +35,7 @@ export const deepEqual = (
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]), options)) {
if (!deepEqual(i[1], b.get(i[0]))) {
return false;
}
}
@@ -103,28 +93,11 @@ export const deepEqual = (
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key], options)) {
if (!deepEqual(a[key], b[key])) {
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,13 +2,9 @@ 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,
ECElementEvent,
} from "echarts/types/src/util/types";
import type { CallbackDataParams } 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";
@@ -25,7 +21,6 @@ export interface Node {
label?: string;
color?: string;
passThrough?: boolean;
entityId?: string;
}
export interface Link {
source: string;
@@ -88,7 +83,6 @@ export class HaSankeyChart extends LitElement {
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
></ha-chart-base>`;
}
@@ -109,22 +103,6 @@ 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();
@@ -316,7 +294,4 @@ 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 type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import Fuse, { type FuseOptionKey } 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,159 +11,46 @@ import type {
SortingDirection,
} from "./ha-data-table";
interface FilterKeyConfig {
key: string;
filterKey?: string;
}
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
const getFilterKeys = memoizeOne(
(columns: SortableColumnContainer): FilterKeyConfig[] =>
Object.entries(columns)
.filter(([, column]) => column.filterable)
.map(([key, column]) => ({
key: column.valueColumn || key,
filterKey: column.filterKey,
}))
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
column.filterKey
? `${column.valueColumn || key}.${column.filterKey}`
: key
);
}
});
return Array.from(searchKeys);
}
);
const getSearchableValue = (
row: DataTableRowData,
{ key, filterKey }: FilterKeyConfig
): string => {
let value = row[key];
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
if (value == null) {
return "";
}
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
): DataTableRowData[] => {
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
) => {
filter = stripDiacritics(filter.toLowerCase());
if (!normalizedFilter) {
if (filter === "") {
return data;
}
const filterKeys = getFilterKeys(columns);
const keys = getSearchKeys(columns);
if (!filterKeys.length) {
return data;
}
const index = fuseIndex(data, keys);
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);
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
};
const sortData = (

View File

@@ -1,9 +1,8 @@
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 { caseInsensitiveStringCompare } from "../../common/string/compare";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -12,12 +11,11 @@ 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,
@@ -30,7 +28,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.
@@ -46,6 +44,12 @@ 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[],
@@ -71,7 +75,7 @@ export abstract class HaDeviceAutomationPicker<
}
private get _value() {
if (!this.value || !this._automations) {
if (!this.value) {
return "";
}
@@ -84,7 +88,7 @@ export abstract class HaDeviceAutomationPicker<
);
if (idx === -1) {
return this.value.alias || this.value.type || "unknown";
return UNKNOWN_AUTOMATION_KEY;
}
return `${this._automations[idx].device_id}_${idx}`;
@@ -95,21 +99,37 @@ export abstract class HaDeviceAutomationPicker<
return nothing;
}
const value = this._value;
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>`;
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>
`;
}
protected updated(changedProps) {
@@ -120,57 +140,6 @@ 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(
@@ -192,14 +161,13 @@ export abstract class HaDeviceAutomationPicker<
this._renderEmpty = false;
}
private _automationChanged(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value || NO_AUTOMATION_KEY === value) {
private _automationChanged(ev) {
const value = ev.target.value;
if (!value || [UNKNOWN_AUTOMATION_KEY, NO_AUTOMATION_KEY].includes(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,7 +7,6 @@ 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,
@@ -96,9 +95,6 @@ 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;
@@ -110,12 +106,7 @@ export class HaStateContentPicker extends LitElement {
private _editIndex?: number;
private _getItems = memoizeOne(
(
entityId?: string,
stateObj?: HassEntity,
allowName?: boolean,
allowContext?: boolean
) => {
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
const domain = entityId ? computeDomain(entityId) : undefined;
const items: PickerComboBoxItem[] = [
{
@@ -158,52 +149,6 @@ 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)
@@ -355,8 +300,7 @@ export class HaStateContentPicker extends LitElement {
const items = this._getItems(
this.entityId,
stateObjForItems,
this.allowName,
this.allowContext
this.allowName
);
return items.find((item) => item.id === value)?.primary;
}
@@ -399,12 +343,7 @@ export class HaStateContentPicker extends LitElement {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
const items = this._getItems(this.entityId, stateObj, this.allowName);
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
@@ -428,12 +367,7 @@ export class HaStateContentPicker extends LitElement {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
const items = this._getItems(this.entityId, stateObj, this.allowName);
// 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, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import type { HomeAssistant } from "../types";
import { listenMediaQuery } from "../common/dom/media_query";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -88,9 +88,6 @@ 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;
@@ -121,33 +118,27 @@ export class HaAdaptiveDialog extends LitElement {
if (this._mode === "bottom-sheet") {
return html`
<ha-bottom-sheet .open=${this.open} flexcontent>
${!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}
<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>
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-bottom-sheet>
@@ -165,7 +156,6 @@ 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,10 +6,16 @@ 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 { areaComboBoxKeys, getAreas } from "../data/area/area_picker";
import { createAreaRegistryEntry } from "../data/area/area_registry";
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 { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
@@ -24,6 +30,12 @@ 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;
@@ -90,8 +102,6 @@ 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 =>
@@ -127,13 +137,183 @@ 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._getAreasMemoized(
this._getAreas(
this.hass.areas,
this.hass.floors,
this.hass.devices,
this.hass.entities,
this.hass.states,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -214,7 +394,7 @@ export class HaAreaPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
.searchKeys=${areaComboBoxKeys}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}

View File

@@ -255,7 +255,6 @@ 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,16 +90,6 @@ 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;
@@ -113,15 +103,6 @@ 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/area_registry";
import { updateAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,

View File

@@ -1,4 +1,4 @@
import { mdiPlus } from "@mdi/js";
import { mdiLabel, mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
@@ -25,9 +25,11 @@ 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) {
@@ -106,10 +108,52 @@ 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 = () =>
this._getLabelsMemoized(
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(
this.hass.states,
this.hass.areas,
this.hass.devices,
@@ -122,6 +166,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -174,6 +219,8 @@ 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}
@@ -190,6 +237,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
@@ -203,6 +251,10 @@ 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(valign, middle);
vertical-align: attr(align, center);
}
td {
vertical-align: attr(valign, middle);
vertical-align: attr(align, left);
}
}
table {

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiClose, mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -26,8 +26,6 @@ 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";
@@ -149,9 +147,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ attribute: "selected-section" }) public selectedSection?: string;
@property({ type: Boolean, reflect: true }) public clearable = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@@ -164,7 +160,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;
@@ -211,17 +207,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return html`<ha-textfield
.label=${searchLabel}
@blur=${this._resetSelectedItem}
@input=${this._filterChanged}
.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>
></ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
@@ -257,7 +244,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
@@ -290,18 +276,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,
});
}
}
@@ -417,22 +403,9 @@ 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();
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"));
}
fireEvent(this, "value-changed", { value: newValue });
};
private _fuseIndex = memoizeOne(
@@ -514,8 +487,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);
}
}
@@ -538,13 +511,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;
@@ -578,14 +551,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;
@@ -607,13 +580,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;
@@ -624,13 +597,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;
@@ -640,14 +613,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");
});
@@ -655,20 +628,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickSelectedItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
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);
}
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (this._virtualizerElement?.items.length === 1) {
fireEvent(this, "value-changed", {
value: firstItem.id,
});
return;
}
if (this._selectedItemIndex === -1) {
@@ -678,16 +643,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) {
this._fireSelectedEvents(item.id, this._selectedItemIndex);
fireEvent(this, "value-changed", { value: item.id });
}
};
private _resetSelectedItem() {
this.virtualizerElement
this._virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._selectedItemIndex = -1;
@@ -697,11 +662,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
@@ -726,10 +691,6 @@ 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);
@@ -831,9 +792,8 @@ 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-1) var(--ha-space-4);
padding: var(--ha-space-2) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
@@ -862,7 +822,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-4));
width: calc(100% - var(--ha-space-8));
}
.section-title.show {
@@ -886,8 +846,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-picker-combo-box": HaPickerComboBox;
}
interface HASSDomEvents {
"index-selected": { index: number };
}
}

View File

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

View File

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

View File

@@ -37,7 +37,6 @@ 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,7 +589,10 @@ 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 (new Date().getTime() < this._recentKeydownActiveUntil) {
if (
this.alwaysExpand ||
new Date().getTime() < this._recentKeydownActiveUntil
) {
return;
}
if (this._mouseLeaveTimeout) {
@@ -609,7 +612,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _listboxFocusIn(ev) {
if (ev.target.localName !== "ha-md-list-item") {
if (this.alwaysExpand || ev.target.localName !== "ha-md-list-item") {
return;
}
this._showTooltip(ev.target);
@@ -649,14 +652,6 @@ 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));
@@ -667,7 +662,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
tooltip.innerText = itemText?.innerText ?? "";
tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement
).innerText;
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
@@ -731,8 +728,6 @@ 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)
);
@@ -741,7 +736,6 @@ 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));
@@ -756,28 +750,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
margin-left: 3px;
margin-inline-start: 3px;
margin-inline-end: initial;
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;
width: 100%;
display: none;
}
:host([narrow]) .title {
margin: 0;
padding: 0 var(--ha-space-4);
}
:host([expanded]) .title {
max-width: 100%;
opacity: 1;
transform: none;
transition-delay: 0ms, 80ms, 80ms;
}
:host(:not([expanded])) .title {
margin: 0;
display: initial;
}
.hidden-panel {
display: none;
@@ -819,7 +800,6 @@ 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;
@@ -860,29 +840,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
ha-md-list-item .item-text {
display: block;
max-width: 0;
opacity: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transform: translateX(-4px);
display: none;
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 {
@@ -950,9 +913,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
position: absolute;
opacity: 0.9;
border-radius: var(--ha-border-radius-sm);
max-width: calc(var(--ha-space-20) * 3);
white-space: normal;
overflow-wrap: break-word;
white-space: nowrap;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);
@@ -963,15 +924,6 @@ 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, nothing } from "lit";
import { css, html, LitElement } from "lit";
import {
customElement,
eventOptions,
@@ -106,9 +106,6 @@ 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;
@@ -150,35 +147,29 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
${!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}
<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>
<div class="content-wrapper">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>

View File

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

View File

@@ -1,204 +0,0 @@
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

@@ -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/area_registry";
import type { AreaRegistryEntry } from "./area_registry";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,

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

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

View File

@@ -11,8 +11,6 @@ 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";
@@ -74,40 +72,6 @@ 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) {

View File

@@ -1,327 +0,0 @@
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: string;
value: any;
label: string;
description?: string;
image?: string | SelectBoxOptionImage;
@@ -501,7 +501,6 @@ 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";

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -186,11 +186,7 @@ class PanelClimate extends LitElement {
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
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;
transition: box-shadow 200ms linear;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
@@ -215,11 +211,6 @@ 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/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area/area_registry";
} from "../../../data/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/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 "../../../components/ha-wa-dialog";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import {
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area/area_registry";
} from "../../../data/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/area_registry";
import { updateAreaRegistryEntry } from "../../../data/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/area_registry";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import {
deleteAreaRegistryEntry,
updateAreaRegistryEntry,
} from "../../../data/area/area_registry";
} from "../../../data/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/area_registry";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import {
createAreaRegistryEntry,
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area/area_registry";
} from "../../../data/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/area_registry";
} from "../../../data/area_registry";
export interface AreaRegistryDetailDialogParams {
entry?: AreaRegistryEntry;

View File

@@ -58,7 +58,6 @@ import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type {
Action,
DeviceAction,
NonConditionAction,
RepeatAction,
ServiceAction,
@@ -234,13 +233,6 @@ 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`
@@ -262,7 +254,9 @@ export default class HaAutomationActionRow extends LitElement {
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}
${target ? this._renderTargets(target) : nothing}
${type === "service" && "target" in this.action
? this._renderTargets((this.action as ServiceAction).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -863,7 +857,6 @@ 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/area_registry";
import type { FloorComboBoxItem } from "../../../data/area_floor_picker";
} from "../../../data/area_registry";
import {
DYNAMIC_PREFIX,
getValueFromDynamic,
@@ -232,8 +232,6 @@ class DialogAddAutomationElement
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _closing = false;
// #endregion variables
// #region lifecycle
@@ -349,8 +347,6 @@ 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;
@@ -364,7 +360,6 @@ class DialogAddAutomationElement
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._open = true;
this._closing = false;
this._params = undefined;
this._selectedCollectionIndex = undefined;
this._selectedGroup = undefined;
@@ -436,24 +431,6 @@ 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;
@@ -719,7 +696,6 @@ 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 &&
@@ -1715,9 +1691,9 @@ class DialogAddAutomationElement
// #region interaction
private _close = () => {
private _close() {
this._open = false;
};
}
private _back() {
mainWindow.history.back();
@@ -1899,10 +1875,7 @@ class DialogAddAutomationElement
}
private _handleClosed() {
// if closing isn't already in progress, close the dialog
if (!this._closing) {
this.closeDialog();
}
this.closeDialog();
}
// #region interaction

View File

@@ -28,10 +28,6 @@ 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,
@@ -39,6 +35,10 @@ import {
type FloorNestedComboBoxItem,
type UnassignedAreasFloorComboBoxItem,
} from "../../../../data/area_floor_picker";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../../data/area_registry";
import {
getConfigEntries,
type ConfigEntry,
@@ -1504,7 +1504,14 @@ 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);
z-index: 2;
}
@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);
}
}
@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, type TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -39,8 +39,6 @@ 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!: (
@@ -81,9 +79,6 @@ 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,
@@ -204,7 +199,6 @@ export class HaAutomationAddItems extends LitElement {
static styles = css`
:host {
display: flex;
flex-grow: 1;
}
:host([scrollable]) .items {
overflow: auto;
@@ -219,24 +213,13 @@ export class HaAutomationAddItems extends LitElement {
background-color: var(--ha-color-surface-default);
align-items: center;
color: var(--ha-color-text-secondary);
padding: var(--ha-space-4);
padding: 0;
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,7 +76,6 @@ 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;
@@ -185,14 +184,6 @@ 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"
@@ -203,7 +194,10 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${target ? this._renderTargets(target) : nothing}
${"target" in
(this.conditionDescriptions[this.condition.condition] || {})
? this._renderTargets((this.condition as PlatformCondition).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -839,7 +833,6 @@ 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/area_registry";
import { createAreaRegistryEntry } from "../../../data/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.voice_assistants.expose.headers.assistants"
"ui.panel.config.automation.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (automation) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -350,7 +350,6 @@ 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,7 +56,6 @@ 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";
@@ -197,15 +196,6 @@ 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
@@ -220,7 +210,11 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${target ? this._renderTargets(target) : nothing}
${type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? this._renderTargets((this.trigger as PlatformTrigger).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -815,7 +809,6 @@ 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,6 +152,7 @@ class HaBackupConfigAgents extends LitElement {
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,10 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-dialog-footer";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -26,21 +28,14 @@ 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 });
@@ -52,46 +47,47 @@ class DialogAssignCategory extends LitElement {
}
const entry = this._params.entityReg.categories[this._params.scope];
return html`
<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}
<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")
)}
>
${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>
${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>
</div>
<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>
<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>
`;
}

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 "../../../components/ha-wa-dialog";
import "../../../components/ha-dialog-footer";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-settings-row";
import "../../../components/ha-button";
import "../../../components/ha-textfield";
import type {
@@ -30,14 +30,11 @@ 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;
@@ -49,10 +46,6 @@ 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 });
@@ -65,55 +58,61 @@ class DialogCategoryDetail extends LitElement {
const entry = this._params.entry;
const nameInvalid = !this._isNameValid();
return html`
<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}
<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")
)}
>
${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>
<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>
<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>
<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>
</div>
<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>
<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>
`;
}

View File

@@ -20,6 +20,7 @@ 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) {
@@ -100,11 +101,17 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
);
private _getCategories = memoizeOne(
(
categories: CategoryRegistryEntry[] | undefined
): PickerComboBoxItem[] | undefined => {
if (!categories) {
return undefined;
(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,
},
];
}
const items = categories.map<PickerComboBoxItem>((category) => ({
@@ -203,6 +210,10 @@ 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,7 +33,10 @@ import {
checkForEntityUpdates,
filterUpdateEntitiesParameterized,
} from "../../../data/update";
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import {
QuickBarMode,
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";
@@ -372,6 +375,7 @@ 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.matter_info"
"ui.panel.config.matter.device_info.device_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/area_registry";
import { createAreaRegistryEntry } from "../../../data/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.voice_assistants.expose.headers.assistants"
"ui.panel.config.entities.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (entry) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entities,

View File

@@ -227,6 +227,7 @@ 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.voice_assistants.expose.headers.assistants"
"ui.panel.config.helpers.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (helper) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

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

View File

@@ -57,6 +57,7 @@ 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,12 +23,23 @@ 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;
@@ -221,7 +232,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
filter=${this.address || ""}
clickable
.tabs=${bluetoothTabs}
.tabs=${bluetoothAdvertisementMonitorTabs}
></hass-tabs-subpage-data-table>
`;
}

View File

@@ -1,10 +1,4 @@
import {
mdiBroadcast,
mdiCogOutline,
mdiLan,
mdiLinkVariant,
mdiNetwork,
} from "@mdi/js";
import { mdiCogOutline } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -14,6 +8,7 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import type {
BluetoothAllocationsData,
BluetoothScannerState,
@@ -29,44 +24,16 @@ 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-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
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,
},
];
import type { HomeAssistant } from "../../../../../types";
@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[] = [];
@@ -155,12 +122,10 @@ export class BluetoothConfigDashboard extends LitElement {
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
<hass-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
@@ -170,8 +135,60 @@ 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-tabs-subpage>
</hass-subpage>
`;
}

View File

@@ -24,7 +24,6 @@ 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 {
@@ -215,7 +214,6 @@ 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-tabs-subpage";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import { bluetoothTabs } from "./bluetooth-config-dashboard";
import { bluetoothAdvertisementMonitorTabs } from "./bluetooth-advertisement-monitor";
const UPDATE_THROTTLE_TIME = 10000;
@@ -123,7 +123,8 @@ export class BluetoothNetworkVisualization extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${bluetoothTabs}
header=${this.hass.localize("ui.panel.config.bluetooth.visualization")}
.tabs=${bluetoothAdvertisementMonitorTabs}
>
<ha-network-graph
.hass=${this.hass}

View File

@@ -203,6 +203,7 @@ 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,31 +175,24 @@ export class HaConfigLovelaceDashboards extends LitElement {
template: narrow
? undefined
: (dashboard) => html`
<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>
${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}
`,
},
};

View File

@@ -75,6 +75,7 @@ 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,6 +58,7 @@ 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/area_registry";
import { createAreaRegistryEntry } from "../../../data/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.voice_assistants.expose.headers.assistants"
"ui.panel.config.scene.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (scene) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
@@ -1189,19 +1189,13 @@ ${rejected
private async _duplicate(scene) {
if (scene.attributes.id) {
const config = await getSceneConfig(this.hass, scene.attributes.id);
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
);
showSceneEditor({
...config,
id: undefined,
name: `${config?.name} (${this.hass.localize(
"ui.panel.config.scene.picker.duplicate"
)})`,
});
}
}

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/area_registry";
import { createAreaRegistryEntry } from "../../../data/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.voice_assistants.expose.headers.assistants"
"ui.panel.config.script.picker.headers.voice_assistants"
),
type: "flex",
type: "icon",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "100px",
maxWidth: "100px",
template: (script) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,

View File

@@ -8,8 +8,6 @@ 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";
@@ -20,16 +18,10 @@ 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;
@@ -40,12 +32,6 @@ 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;
}
@@ -155,101 +141,38 @@ class DialogExposeEntity extends LitElement {
(
exposedEntities: Record<string, ExposeEntitySettings>,
filter?: string
): FilteredEntity[] => {
) => {
const lowerFilter = filter?.toLowerCase();
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;
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))
);
}
);
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 _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 _expose() {
this._params!.exposeEntities(this._selected);
@@ -275,7 +198,6 @@ 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 {
@@ -288,7 +210,7 @@ class DialogExposeEntity extends LitElement {
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: calc(var(--ha-space-1) * -1) 0;
margin: -4px 0;
}
.subtitle {
color: var(--secondary-text-color);
@@ -303,17 +225,9 @@ 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: var(--ha-space-6);
margin-inline-start: var(--ha-space-6);
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
}
@media all and (max-height: 800px) {
@@ -348,8 +262,8 @@ class DialogExposeEntity extends LitElement {
--text-field-suffix-padding-left: unset;
}
ha-check-list-item ha-state-icon {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
}
}

View File

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

View File

@@ -160,10 +160,6 @@ 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(
@@ -174,11 +170,6 @@ 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,11 +186,7 @@ class PanelLight extends LitElement {
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
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;
transition: box-shadow 200ms linear;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
@@ -215,11 +211,6 @@ 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,6 +160,7 @@ 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/area_registry";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import { forwardHaptic } from "../../../data/haptics";
import { computeCssVariable } from "../../../resources/css-variables";
import type { HomeAssistant } from "../../../types";

View File

@@ -2,6 +2,8 @@ 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";
@@ -26,6 +28,11 @@ 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;
@@ -40,27 +47,32 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
@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();
}
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeOffsets();
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
this._stopTick();
}
protected updated(changedProps: PropertyValues) {
@@ -72,30 +84,78 @@ export class HuiClockCardAnalog extends LitElement {
}
}
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
}
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._computeDateTime();
}
};
private _computeOffsets() {
private _initDate() {
if (!this.config || !this.hass) {
return;
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
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",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeDateTime();
}
private _startTick() {
this._tick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
}
private _stopTick() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = undefined;
}
}
private _updateDate() {
this._currentDate = new Date();
}
private _computeDateTime() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
this._updateDate();
const parts = this._dateTimeFormat.formatToParts(this._currentDate);
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;
@@ -103,7 +163,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 = new Date().getMilliseconds();
const ms = this._currentDate.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -111,18 +171,38 @@ 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 = 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 { sizeClass, isNumbers, isRoman, isUpright, isLongDate } =
this._computeClock(this.config);
const indicator = (number?: number) => html`
<div
@@ -163,14 +243,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -178,28 +258,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -208,14 +290,33 @@ 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=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${this.config.show_seconds
? html`<div
@@ -224,11 +325,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
></div>`
: nothing}
</div>
@@ -270,6 +373,14 @@ 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);
@@ -407,6 +518,78 @@ 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,6 +24,8 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _initDate() {
@@ -39,6 +41,27 @@ 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",
@@ -93,6 +116,16 @@ 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() {
@@ -113,6 +146,9 @@ 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}
`;
}
@@ -188,6 +224,20 @@ 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,7 +5,6 @@ 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,
@@ -327,7 +326,6 @@ class HuiPowerSankeyCard
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: device.stat_rate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
@@ -463,7 +461,6 @@ 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"
@@ -478,13 +475,6 @@ 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,18 +73,13 @@ 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) {
if (configOptions.rows === undefined) {
mergedConfig.rows = elementOptions.rows;
}
mergedConfig.rows = elementOptions.rows;
delete mergedConfig.min_rows;
delete mergedConfig.max_rows;
}
if (elementOptions.fixed_columns) {
if (configOptions.columns === undefined) {
mergedConfig.columns = elementOptions.columns;
}
mergedConfig.columns = elementOptions.columns;
delete mergedConfig.min_columns;
delete mergedConfig.max_columns;
}

View File

@@ -11,10 +11,7 @@ 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 {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "./types";
import type { PictureElementsCardConfig } from "./types";
import type { PersonEntity } from "../../../data/person";
@customElement("hui-picture-elements-card")
@@ -169,7 +166,6 @@ 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>
@@ -225,19 +221,6 @@ 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,6 +421,7 @@ 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";
@@ -487,10 +488,6 @@ 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;
@@ -505,7 +502,6 @@ 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,6 +39,19 @@ 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(
@@ -93,7 +106,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +132,27 @@ 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"
? ([
{
@@ -260,13 +294,14 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
private _data = memoizeOne((config: ClockCardConfig) => ({
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",
@@ -290,8 +325,9 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -367,6 +403,10 @@ 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`
@@ -392,6 +432,10 @@ 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,16 +15,12 @@ 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 {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "../../cards/types";
import type { PictureElementsCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
@@ -32,6 +28,7 @@ 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({
@@ -69,44 +66,6 @@ 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) =>
[
@@ -179,16 +138,6 @@ 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}
@@ -232,7 +181,6 @@ 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 });
}
@@ -243,8 +191,7 @@ export class HuiPictureElementsCardEditor
const config = {
...this._config,
elements: ev.detail.elements as LovelaceElementConfig[],
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
} as PictureElementsCardConfig;
} as LovelaceCardConfig;
fireEvent(this, "config-changed", { config });
@@ -285,12 +232,7 @@ export class HuiPictureElementsCardEditor
elementConfig: value,
};
fireEvent(this, "config-changed", {
config: {
...this._config,
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
},
});
fireEvent(this, "config-changed", { config: this._config });
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {

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