mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-30 10:16:17 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9699851c26 | |||
| a318593c9f | |||
| 749dd180ae | |||
| 247780e4c8 | |||
| 7991090313 | |||
| 2efa0bd86d | |||
| 0fad4f0097 | |||
| 2b90c9a628 | |||
| f3848df19d | |||
| e2a91c358d | |||
| 8793cb58b9 | |||
| 2442bf662a | |||
| e42be9f4d6 | |||
| a096f8383d | |||
| a3e59d64fc | |||
| e90e8169cd | |||
| 959a798b5c | |||
| 9c117d446e | |||
| 11913292b1 | |||
| 2dabc28e7f | |||
| fd70fcef2f | |||
| 9bf46415f1 | |||
| c552af4c12 | |||
| c573669786 | |||
| 4e1ccab159 | |||
| 347d63bce9 | |||
| a1d7b31732 | |||
| 4d61df7ed7 | |||
| 991dda70fc | |||
| 58f5480ca3 | |||
| 1f20bc0749 | |||
| 0846cb3be3 | |||
| 33afb77367 | |||
| b2f85e2595 | |||
| 09956a7d9c | |||
| 8507e222f8 | |||
| 5737480398 | |||
| 44163b9ccb | |||
| e79cd0c5b2 | |||
| 52379b39e0 | |||
| 656e1bea8e | |||
| 4ef3ed2f02 | |||
| fbad0ba885 | |||
| c892691344 | |||
| 811b7c7d98 | |||
| 91dee86697 | |||
| 9877377cb9 | |||
| 7a1c8c556f | |||
| 2c3d8eb230 | |||
| 67489affe7 | |||
| 7fcbd8e245 | |||
| 12a88231f3 | |||
| f66619fae6 | |||
| 43ff58010a | |||
| c391d571d7 | |||
| 18e15f8a99 | |||
| dfdd55b649 | |||
| bed98776c3 | |||
| ad37f1bb58 | |||
| 2a00b0d0ec | |||
| 20efc35da3 | |||
| ac71b4c400 | |||
| b85422e652 | |||
| 4ff69aab8f | |||
| ebb15d1118 |
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 561513,
|
||||
"core": 54473,
|
||||
"authorize": 544272,
|
||||
"onboarding": 647136
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 790323,
|
||||
"core": 237208,
|
||||
"authorize": 765464,
|
||||
"onboarding": 918679
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,42 @@
|
||||
const remapping = require("@ampproject/remapping");
|
||||
|
||||
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
|
||||
let minifyPromise;
|
||||
const getMinifier = () => {
|
||||
if (!minifyPromise) {
|
||||
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
|
||||
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
|
||||
const loaderInitPromises = new Map();
|
||||
const initLoader = (browserslistEnv) => {
|
||||
if (!loaderInitPromises.has(browserslistEnv)) {
|
||||
loaderInitPromises.set(
|
||||
browserslistEnv,
|
||||
Promise.all([
|
||||
import("minify-literals"),
|
||||
import("browserslist"),
|
||||
import("lightningcss"),
|
||||
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
|
||||
const browserslist = browserslistModule.default;
|
||||
const { browserslistToTargets } = lightningcssModule;
|
||||
const rawTargets = browserslist(null, { env: browserslistEnv });
|
||||
if (!rawTargets.length) {
|
||||
throw new Error(
|
||||
`No browsers resolved for browserslist environment "${browserslistEnv}"`
|
||||
);
|
||||
}
|
||||
const lightningcssTargets = browserslistToTargets(rawTargets);
|
||||
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return minifyPromise;
|
||||
return loaderInitPromises.get(browserslistEnv);
|
||||
};
|
||||
|
||||
// HTML options mirror the previous babel-plugin-template-html-minifier config
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
// css`` templates and inline <style> is handled by minify-literals'
|
||||
// lightningcss. We pass in the targets from browserslist so lightningcss
|
||||
// can minify CSS appropriately for the build environment.
|
||||
//
|
||||
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
|
||||
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
|
||||
@@ -40,11 +64,16 @@ const htmlOptions = {
|
||||
|
||||
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const callback = this.async();
|
||||
getMinifier()
|
||||
.then((minifyHTMLLiterals) =>
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
|
||||
@@ -96,6 +96,11 @@ const createRspackConfig = ({
|
||||
__dirname,
|
||||
"minify-template-literals-loader.cjs"
|
||||
),
|
||||
options: {
|
||||
browserslistEnv: latestBuild
|
||||
? "modern"
|
||||
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
|
||||
},
|
||||
},
|
||||
!latestBuild &&
|
||||
info.resource.startsWith(
|
||||
|
||||
@@ -34,16 +34,8 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
@@ -64,12 +56,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
@@ -104,7 +98,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
+4
-4
@@ -186,14 +186,14 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.8.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jsdom": "30.0.0",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.2.0",
|
||||
@@ -224,12 +224,12 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.8.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
"node": "24.18.1"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.0"
|
||||
version = "20260729.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
let supported: boolean | undefined;
|
||||
|
||||
const detect = (): boolean => {
|
||||
if (
|
||||
!globalThis.ElementInternals ||
|
||||
!globalThis.HTMLElement?.prototype.attachInternals
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Native internals keep their WebIDL brand even when `attachInternals` is
|
||||
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
|
||||
// broke the previous `[native code]` source check in the app bundle).
|
||||
// `element-internals-polyfill` swaps in a plain class, which has no brand,
|
||||
// and must not count as native: login on legacy browsers relies on
|
||||
// validation being skipped there (#51338).
|
||||
return (
|
||||
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
|
||||
"[object ElementInternals]"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates whether the current browser has native ElementInternals support.
|
||||
* Probed on first use so importing this module has no side effects.
|
||||
*/
|
||||
export const nativeElementInternalsSupported =
|
||||
Boolean(globalThis.ElementInternals) &&
|
||||
globalThis.HTMLElement?.prototype.attachInternals
|
||||
?.toString()
|
||||
.includes("[native code]");
|
||||
export const supportsNativeElementInternals = (): boolean => {
|
||||
supported ??= detect();
|
||||
return supported;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* Checks that a path resolves to the origin the frontend is served from, so it
|
||||
* is safe to use as a link target. Rejects URIs that carry their own scheme,
|
||||
* like `javascript:`, and URLs pointing at another origin. Resolves against the
|
||||
* main window, because that is where `navigate()` applies the path.
|
||||
*/
|
||||
const isSameOriginPath = (path: string): boolean => {
|
||||
try {
|
||||
const { origin } = mainWindow.location;
|
||||
return new URL(path, origin).origin === origin;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
|
||||
* paths that can be influenced by a URL parameter or by dashboard config before
|
||||
* they end up in an `href` or in `navigate()`.
|
||||
*/
|
||||
export const sanitizeNavigationPath = (
|
||||
path: string | null | undefined
|
||||
): string | undefined =>
|
||||
path != null && isSameOriginPath(path) ? path : undefined;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
@@ -483,13 +484,7 @@ export class HaDataTable extends LitElement {
|
||||
: ""
|
||||
}
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ??
|
||||
column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
) {
|
||||
if (!this._isColumnVisible(key, column)) {
|
||||
return nothing;
|
||||
}
|
||||
const sorted = key === this.sortColumn;
|
||||
@@ -657,10 +652,7 @@ export class HaDataTable extends LitElement {
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
(narrow && !column.main && !column.showNarrow) ||
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
!this._isColumnVisible(key, column)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -692,28 +684,19 @@ export class HaDataTable extends LitElement {
|
||||
: narrow && column.main
|
||||
? html`<div class="primary">${row[key]}</div>
|
||||
<div class="secondary">
|
||||
${Object.entries(columns)
|
||||
.filter(
|
||||
([key2, column2]) =>
|
||||
!column2.hidden &&
|
||||
!column2.main &&
|
||||
!column2.showNarrow &&
|
||||
!(this.columnOrder &&
|
||||
this.columnOrder.includes(key2)
|
||||
? (this.hiddenColumns?.includes(key2) ??
|
||||
column2.defaultHidden)
|
||||
: column2.defaultHidden)
|
||||
)
|
||||
.map(
|
||||
([key2, column2], i) =>
|
||||
html`${
|
||||
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
|
||||
}${
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
}`
|
||||
)}
|
||||
${join(
|
||||
Object.entries(columns)
|
||||
.filter(([key2, column2]) =>
|
||||
this._isSecondaryColumnVisible(key2, column2)
|
||||
)
|
||||
.map(([key2, column2]) =>
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
)
|
||||
.filter(this._hasCellValue),
|
||||
STRINGS_SEPARATOR_DOT
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
column.extraTemplate
|
||||
@@ -733,6 +716,29 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
|
||||
if (column.hidden) {
|
||||
return false;
|
||||
}
|
||||
if (!this.columnOrder?.includes(key)) {
|
||||
return !column.defaultHidden;
|
||||
}
|
||||
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
|
||||
}
|
||||
|
||||
private _isSecondaryColumnVisible(
|
||||
key: string,
|
||||
column: DataTableColumnData
|
||||
): boolean {
|
||||
if (column.main || column.showNarrow) {
|
||||
return false;
|
||||
}
|
||||
return this._isColumnVisible(key, column);
|
||||
}
|
||||
|
||||
private _hasCellValue = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null && value !== "" && value !== nothing;
|
||||
|
||||
private async _sortFilterData() {
|
||||
const startTime = new Date().getTime();
|
||||
const timeBetweenUpdate = startTime - this._lastUpdate;
|
||||
|
||||
@@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-dialog";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-button";
|
||||
@@ -23,11 +26,21 @@ export class DialogDeviceReplaced
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
private async _loadConfigEntries(): Promise<void> {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
configEntryLookup: Record<string, ConfigEntry> | undefined
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
const configEntry = device?.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
return {
|
||||
deviceId,
|
||||
name: device ? computeDeviceName(device) : deviceId,
|
||||
secondary: area ? computeAreaName(area) : undefined,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
domain: configEntry?.domain,
|
||||
domainName: configEntry
|
||||
? domainToName(this.hass.localize, configEntry.domain)
|
||||
: undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
).map(
|
||||
(item) => html`
|
||||
this.hass.areas,
|
||||
this._configEntryLookup
|
||||
).map((item) => {
|
||||
const supportingText = [
|
||||
item.area,
|
||||
item.domainName,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
return html`
|
||||
<ha-list-item-button .deviceId=${item.deviceId}>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
${
|
||||
item.domain
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span slot="headline">${item.name}</span>
|
||||
${
|
||||
item.secondary || item.isPrimary
|
||||
? html`<span slot="supporting-text">
|
||||
${[
|
||||
item.secondary,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ")}
|
||||
</span>`
|
||||
supportingText
|
||||
? html`<span slot="supporting-text"
|
||||
>${supportingText}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -132,6 +176,10 @@ export class DialogDeviceReplaced
|
||||
padding: 0 var(--ha-space-6) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button-toolbar";
|
||||
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
|
||||
import type {
|
||||
HaIconButtonToolbar,
|
||||
HaIconButtonToolbarItem,
|
||||
} from "./ha-icon-button-toolbar";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
@property({ type: Boolean, attribute: "has-test" })
|
||||
public hasTest = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
|
||||
|
||||
@property({ attribute: false }) public testing = false;
|
||||
|
||||
@property({ type: String }) public placeholder?: string;
|
||||
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
changedProps.has("_canCopy") ||
|
||||
changedProps.has("_canUndo") ||
|
||||
changedProps.has("_canRedo") ||
|
||||
changedProps.has("testing")
|
||||
changedProps.has("testing") ||
|
||||
changedProps.has("toolbarItems")
|
||||
) {
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
this._editorToolbar.items = [
|
||||
...(this.toolbarItems ?? []),
|
||||
...(this.hasTest && !this._isFullscreen
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { subscribeLabFeature } from "../data/labs";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
@@ -81,14 +82,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
|
||||
class="snowflake ${
|
||||
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
|
||||
}"
|
||||
style="
|
||||
left: ${flake.left}%;
|
||||
width: ${flake.size}px;
|
||||
height: ${flake.size}px;
|
||||
animation-duration: ${flake.duration}s;
|
||||
animation-delay: ${flake.delay}s;
|
||||
--rotation: ${flake.rotation}deg;
|
||||
"
|
||||
style=${styleMap({
|
||||
left: `${flake.left}%`,
|
||||
width: `${flake.size}px`,
|
||||
height: `${flake.size}px`,
|
||||
"animation-duration": `${flake.duration}s`,
|
||||
"animation-delay": `${flake.delay}s`,
|
||||
"--rotation": `${flake.rotation}deg`,
|
||||
})}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-split-panel")
|
||||
export class HaSplitPanel extends SplitPanel {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
SplitPanel.styles,
|
||||
css`
|
||||
:host {
|
||||
--divider-width: var(--ha-split-panel-divider-width, 2px);
|
||||
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
|
||||
--min: var(--ha-split-panel-min, 0);
|
||||
--max: var(--ha-split-panel-max, 100%);
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: var(--divider-color);
|
||||
transition: background-color var(--ha-animation-duration-fast)
|
||||
ease-out;
|
||||
}
|
||||
|
||||
/* Grip affordance so the divider reads as draggable. The divider
|
||||
already centers its children via flexbox, so keep this in flow.
|
||||
Consumers slotting their own divider handle can hide it with
|
||||
--ha-split-panel-grip-display: none. */
|
||||
.divider::before {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: var(--ha-space-8);
|
||||
display: var(--ha-split-panel-grip-display, block);
|
||||
border-radius: var(--ha-border-radius-pill, 9999px);
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
/* In vertical orientation the divider is horizontal, so the grip pill
|
||||
lies flat instead of standing upright. */
|
||||
:host([orientation="vertical"]) .divider::before {
|
||||
width: var(--ha-space-8);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:host(:not([disabled])) .divider:hover {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
:host(:not([disabled])) .divider:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host(:not([disabled])) .divider:focus-visible {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-split-panel": HaSplitPanel;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type LitElement, css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
|
||||
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
|
||||
import type { Constructor } from "../../types";
|
||||
|
||||
/**
|
||||
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
}
|
||||
|
||||
public checkValidity(): boolean {
|
||||
return nativeElementInternalsSupported
|
||||
return supportsNativeElementInternals()
|
||||
? (this._formControl?.checkValidity() ?? true)
|
||||
: true;
|
||||
}
|
||||
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
|
||||
protected _handleInput(): void {
|
||||
this.value = this._formControl?.value ?? undefined;
|
||||
if (this._invalid && this._formControl?.checkValidity()) {
|
||||
if (this._invalid && this.checkValidity()) {
|
||||
this._invalid = false;
|
||||
}
|
||||
}
|
||||
@@ -222,12 +222,16 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
|
||||
|
||||
protected _handleBlur(): void {
|
||||
if (this.autoValidate) {
|
||||
this._invalid = !this._formControl?.checkValidity();
|
||||
this._invalid = !this.checkValidity();
|
||||
}
|
||||
}
|
||||
|
||||
protected _handleInvalid(): void {
|
||||
this._invalid = true;
|
||||
// Polyfilled internals dispatch `invalid` themselves, so only trust the
|
||||
// event when validity comes from the platform (#51338).
|
||||
if (supportsNativeElementInternals()) {
|
||||
this._invalid = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from "../../data/media_source";
|
||||
import { isTTSMediaSource } from "../../data/tts";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { panelIsReady } from "../../layouts/panel-ready";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -160,8 +159,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _initialReady = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.updateComplete.then(() => this._attachResizeObserver());
|
||||
@@ -275,7 +272,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
ids: navigateIds,
|
||||
current: this._currentItem,
|
||||
});
|
||||
this._signalInitialReady();
|
||||
} else {
|
||||
if (!currentProm) {
|
||||
currentProm = this._fetchData(
|
||||
@@ -291,7 +287,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
ids: navigateIds,
|
||||
current: item,
|
||||
});
|
||||
this._signalInitialReady();
|
||||
},
|
||||
(err) => {
|
||||
// When we change entity ID, we will first try to see if the new entity is
|
||||
@@ -325,10 +320,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
),
|
||||
code: "entity_not_found",
|
||||
});
|
||||
this._signalInitialReady();
|
||||
} else {
|
||||
this._setError(err);
|
||||
this._signalInitialReady();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1132,21 +1125,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
fireEvent(this, "close-dialog");
|
||||
}
|
||||
|
||||
private _signalInitialReady(): void {
|
||||
if (this._initialReady) {
|
||||
return;
|
||||
}
|
||||
this._initialReady = true;
|
||||
const root = this.getRootNode();
|
||||
panelIsReady(
|
||||
root instanceof ShadowRoot &&
|
||||
root.host instanceof HTMLElement &&
|
||||
root.host.tagName.startsWith("HA-PANEL-")
|
||||
? root.host
|
||||
: this
|
||||
);
|
||||
}
|
||||
|
||||
private _setError(error: any) {
|
||||
if (!this.dialog) {
|
||||
this._error = error;
|
||||
@@ -1211,8 +1189,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
private _animateHeaderHeight() {
|
||||
let start: number | undefined;
|
||||
const animate = (time: number) => {
|
||||
let start;
|
||||
const animate = (time) => {
|
||||
if (start === undefined) {
|
||||
start = time;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public vertical = false;
|
||||
|
||||
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
|
||||
@property({ type: Boolean, attribute: "fixed-info-height" })
|
||||
public fixedInfoHeight = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public interactive = false;
|
||||
|
||||
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const contentClasses = { vertical: this.vertical };
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
padding: 10px var(--ha-space-2);
|
||||
}
|
||||
.vertical.fixed-info-height {
|
||||
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
|
||||
gap: 2px;
|
||||
--ha-tile-info-gap: 2px;
|
||||
--ha-tile-info-primary-line-height: var(--ha-space-4);
|
||||
--ha-tile-info-primary-min-height: var(--ha-space-8);
|
||||
--ha-tile-info-min-height: var(--ha-space-12);
|
||||
}
|
||||
.vertical ::slotted([slot="info"]) {
|
||||
width: 100%;
|
||||
|
||||
@@ -15,9 +15,13 @@ import { customElement, property } from "lit/decorators";
|
||||
*
|
||||
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
|
||||
*
|
||||
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
|
||||
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
|
||||
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of `--ha-tile-info-primary-line-clamp`. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
|
||||
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
|
||||
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
|
||||
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
|
||||
* @cssprop --ha-tile-info-primary-line-clamp - The maximum number of lines for the primary text before truncating with an ellipsis. defaults to `1`.
|
||||
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
|
||||
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
|
||||
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
|
||||
@@ -59,6 +63,8 @@ export class HaTileInfo extends LitElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
--tile-info-gap: var(--ha-tile-info-gap, 0);
|
||||
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
|
||||
--tile-info-primary-font-size: var(
|
||||
--ha-tile-info-primary-font-size,
|
||||
var(--ha-font-size-m)
|
||||
@@ -71,6 +77,11 @@ export class HaTileInfo extends LitElement {
|
||||
--ha-tile-info-primary-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
--tile-info-primary-line-clamp: var(--ha-tile-info-primary-line-clamp, 1);
|
||||
--tile-info-primary-min-height: var(
|
||||
--ha-tile-info-primary-min-height,
|
||||
auto
|
||||
);
|
||||
--tile-info-primary-letter-spacing: var(
|
||||
--ha-tile-info-primary-letter-spacing,
|
||||
0.1px
|
||||
@@ -106,28 +117,45 @@ export class HaTileInfo extends LitElement {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--tile-info-gap);
|
||||
min-height: var(--tile-info-min-height);
|
||||
}
|
||||
span,
|
||||
::slotted(*) {
|
||||
.secondary span,
|
||||
::slotted([slot="secondary"]) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.primary span,
|
||||
::slotted([slot="primary"]) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--tile-info-primary-line-clamp);
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
width: 100%;
|
||||
}
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-primary-font-size);
|
||||
font-weight: var(--tile-info-primary-font-weight);
|
||||
line-height: var(--tile-info-primary-line-height);
|
||||
letter-spacing: var(--tile-info-primary-letter-spacing);
|
||||
color: var(--tile-info-primary-color);
|
||||
min-height: var(--tile-info-primary-min-height);
|
||||
}
|
||||
.secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: var(--tile-info-secondary-font-size);
|
||||
font-weight: var(--tile-info-secondary-font-weight);
|
||||
line-height: var(--tile-info-secondary-line-height);
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface LovelaceViewElement extends HTMLElement {
|
||||
badges?: HuiBadge[];
|
||||
sections?: HuiSection[];
|
||||
isStrategy: boolean;
|
||||
initialRenderComplete?: Promise<void>;
|
||||
setConfig(config: LovelaceViewConfig): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -314,6 +314,11 @@ export interface ZWaveJSSetConfigParamResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ZwaveJSNodeConfigParameterUpdate {
|
||||
id: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface ZWaveJSDataCollectionStatus {
|
||||
enabled: boolean;
|
||||
opted_in: boolean;
|
||||
@@ -732,6 +737,16 @@ export const fetchZwaveNodeConfigParameters = (
|
||||
device_id,
|
||||
});
|
||||
|
||||
export const subscribeZwaveNodeConfigParameterUpdates = (
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage(callback, {
|
||||
type: "zwave_js/subscribe_config_parameter_updates",
|
||||
device_id,
|
||||
});
|
||||
|
||||
export const setZwaveNodeConfigParameter = (
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
|
||||
@@ -18,38 +18,15 @@
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<meta name="theme-color" content="{{ theme_color }}" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_style_base.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
::view-transition-group(launch-screen) {
|
||||
animation-duration: var(--ha-animation-duration-normal, 250ms);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-old(launch-screen) {
|
||||
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
height: 100vh;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -61,7 +38,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
view-transition-name: launch-screen;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
z-index: 100;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
@@ -98,7 +76,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
|
||||
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: html`<p>
|
||||
: html`<p class=${classMap({ "loading-text": !this.migration })}>
|
||||
${
|
||||
this.migration
|
||||
? html`<span class="migration-text"
|
||||
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
|
||||
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
|
||||
}</span
|
||||
>`
|
||||
: this.localize?.("ui.init.loading") || "Loading data"
|
||||
: this.localize?.("ui.init.loading") || "Loading..."
|
||||
}
|
||||
</p>`;
|
||||
}
|
||||
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
|
||||
.migration-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.loading-text {
|
||||
opacity: 0.66;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
import type { HomeAssistant } from "../types";
|
||||
@@ -37,9 +36,7 @@ class HassLoadingScreen extends LitElement {
|
||||
private _renderContent(): TemplateResult {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
<ha-spinner></ha-spinner>
|
||||
${
|
||||
this.message
|
||||
? html`<div id="loading-text">${this.message}</div>`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -29,16 +30,18 @@ class HassSubpage extends LitElement {
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
return html`
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || history.state?.root
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this.backPath
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
href=${this.backPath}
|
||||
href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -15,6 +15,7 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -164,17 +165,19 @@ export class HassTabsSubpage extends LitElement {
|
||||
this._narrow,
|
||||
this.localizeFunc || this.hass.localize
|
||||
);
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
return html`
|
||||
<div class="toolbar ${classMap({ narrow: this._narrow })}">
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!this.backPath && history.state?.root)
|
||||
this.mainPage || (!backPath && history.state?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this.backPath
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${this.backPath}
|
||||
.href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ContextProvider, createContext } from "@lit/context";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
|
||||
@@ -17,43 +15,6 @@ export const panelIsReady = async (element: HTMLElement) => {
|
||||
fireEvent(element, "hass-panel-ready");
|
||||
};
|
||||
|
||||
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
|
||||
|
||||
export const childPanelReadyContext =
|
||||
createContext<RegisterChildPanelReady>("child-panel-ready");
|
||||
|
||||
export class ChildPanelReady implements ReactiveController {
|
||||
private _promises: Promise<void>[] = [];
|
||||
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
|
||||
private _resolveReady?: () => void;
|
||||
|
||||
public ready = new Promise<void>((resolve) => {
|
||||
this._resolveReady = resolve;
|
||||
});
|
||||
|
||||
public constructor(host: ReactiveControllerHost & HTMLElement) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
new ContextProvider(host, {
|
||||
context: childPanelReadyContext,
|
||||
initialValue: (ready) => this._promises.push(ready),
|
||||
});
|
||||
}
|
||||
|
||||
public hostUpdated() {
|
||||
Promise.all(this._promises).then(
|
||||
() => {
|
||||
this._resolveReady?.();
|
||||
return panelIsReady(this._host);
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
this._host.removeController(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelReady {
|
||||
public ready?: Promise<void>;
|
||||
|
||||
|
||||
@@ -19,66 +19,29 @@ import { HassRouterPage } from "./hass-router-page";
|
||||
|
||||
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
|
||||
const PANEL_READY_TIMEOUT = 2000;
|
||||
const DASHBOARD_READY_TIMEOUT = 5000;
|
||||
const COMPONENTS = {
|
||||
app: { load: () => import("../panels/app/ha-panel-app") },
|
||||
energy: {
|
||||
load: () => import("../panels/energy/ha-panel-energy"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
calendar: {
|
||||
load: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
waitForReady: true,
|
||||
},
|
||||
config: { load: () => import("../panels/config/ha-panel-config") },
|
||||
custom: { load: () => import("../panels/custom/ha-panel-custom") },
|
||||
lovelace: {
|
||||
load: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
history: { load: () => import("../panels/history/ha-panel-history") },
|
||||
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
|
||||
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
|
||||
map: { load: () => import("../panels/map/ha-panel-map") },
|
||||
my: { load: () => import("../panels/my/ha-panel-my") },
|
||||
profile: { load: () => import("../panels/profile/ha-panel-profile") },
|
||||
todo: { load: () => import("../panels/todo/ha-panel-todo") },
|
||||
"media-browser": {
|
||||
load: () => import("../panels/media-browser/ha-panel-media-browser"),
|
||||
waitForReady: true,
|
||||
},
|
||||
light: {
|
||||
load: () => import("../panels/light/ha-panel-light"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
security: {
|
||||
load: () => import("../panels/security/ha-panel-security"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
climate: {
|
||||
load: () => import("../panels/climate/ha-panel-climate"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
maintenance: {
|
||||
load: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
home: {
|
||||
load: () => import("../panels/home/ha-panel-home"),
|
||||
waitForReady: true,
|
||||
readyTimeout: DASHBOARD_READY_TIMEOUT,
|
||||
},
|
||||
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
|
||||
} satisfies Record<
|
||||
string,
|
||||
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
|
||||
>;
|
||||
app: () => import("../panels/app/ha-panel-app"),
|
||||
energy: () => import("../panels/energy/ha-panel-energy"),
|
||||
calendar: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
config: () => import("../panels/config/ha-panel-config"),
|
||||
custom: () => import("../panels/custom/ha-panel-custom"),
|
||||
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
history: () => import("../panels/history/ha-panel-history"),
|
||||
iframe: () => import("../panels/iframe/ha-panel-iframe"),
|
||||
logbook: () => import("../panels/logbook/ha-panel-logbook"),
|
||||
map: () => import("../panels/map/ha-panel-map"),
|
||||
my: () => import("../panels/my/ha-panel-my"),
|
||||
profile: () => import("../panels/profile/ha-panel-profile"),
|
||||
todo: () => import("../panels/todo/ha-panel-todo"),
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
home: () => import("../panels/home/ha-panel-home"),
|
||||
notfound: () => import("../panels/notfound/ha-panel-notfound"),
|
||||
};
|
||||
|
||||
@customElement("partial-panel-resolver")
|
||||
class PartialPanelResolver extends HassRouterPage {
|
||||
@@ -163,12 +126,13 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
private _getRoutes(panels: Panels): RouterOptions {
|
||||
const routes: RouterOptions["routes"] = {};
|
||||
Object.values(panels).forEach((panel) => {
|
||||
const component = COMPONENTS[panel.component_name];
|
||||
const data: RouteOptions = {
|
||||
tag: `ha-panel-${panel.component_name}`,
|
||||
cache: CACHE_URL_PATHS.includes(panel.url_path),
|
||||
...component,
|
||||
};
|
||||
if (panel.component_name in COMPONENTS) {
|
||||
data.load = COMPONENTS[panel.component_name];
|
||||
}
|
||||
routes[panel.url_path] = data;
|
||||
});
|
||||
|
||||
@@ -257,12 +221,9 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
)
|
||||
) {
|
||||
await this.rebuild();
|
||||
const component =
|
||||
COMPONENTS[this.hass.panels[this._currentPage].component_name];
|
||||
await promiseTimeout(
|
||||
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
|
||||
this.pageRendered
|
||||
).catch(() => undefined);
|
||||
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
|
||||
() => undefined
|
||||
);
|
||||
// Only fire frontend/loaded when this call actually removed the launch
|
||||
// screen, so later panel updates do not fire it again. Native apps remove
|
||||
// it instantly because their own splash screen is still visible.
|
||||
|
||||
@@ -35,7 +35,6 @@ import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
|
||||
import { fetchIntegrationManifest } from "../../data/integration";
|
||||
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { panelIsReady } from "../../layouts/panel-ready";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { CalendarViewChanged, HomeAssistant } from "../../types";
|
||||
@@ -78,8 +77,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _mql?: MediaQueryList;
|
||||
|
||||
private _initialReady = false;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._mql = window.matchMedia(
|
||||
@@ -106,10 +103,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
|
||||
this._entityRegistry = entities;
|
||||
// Refresh calendars when entity registry updates (includes color changes)
|
||||
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
|
||||
if (!this._initialReady) {
|
||||
this._initialReady = true;
|
||||
panelIsReady(this);
|
||||
}
|
||||
// Resubscribe events if view dates are available (handles both initial load and color updates)
|
||||
if (this._start && this._end) {
|
||||
this._unsubscribeAll().then(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -32,8 +31,6 @@ class PanelClimate extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -130,7 +127,6 @@ class PanelClimate extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -346,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -203,12 +203,10 @@ export function getModifiedAtTableColumn<T>(
|
||||
}
|
||||
|
||||
const renderDateTimeColumn = (valueDateTime: number, hass: HomeAssistant) =>
|
||||
html`${
|
||||
valueDateTime
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(valueDateTime * 1000),
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
: nothing
|
||||
}`;
|
||||
valueDateTime
|
||||
? formatShortDateTimeWithConditionalYear(
|
||||
new Date(valueDateTime * 1000),
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
: nothing;
|
||||
|
||||
@@ -36,7 +36,6 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
|
||||
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import { ChildPanelReady } from "../../../layouts/panel-ready";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -158,11 +157,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
new ChildPanelReady(this);
|
||||
}
|
||||
|
||||
private _pages = memoizeOne(
|
||||
(
|
||||
cloudStatus,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
|
||||
@@ -8,10 +7,6 @@ import "../../../components/ha-icon-next";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { getConfigEntries } from "../../../data/config_entries";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-config-navigation-list";
|
||||
|
||||
@@ -23,20 +18,21 @@ class HaConfigNavigation extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public pages!: PageNavigation[];
|
||||
|
||||
@state() private _visiblePages?: PageNavigation[];
|
||||
@state() private _hasBluetoothConfigEntries = false;
|
||||
|
||||
private _hasBluetoothConfigEntries = false;
|
||||
|
||||
@consume({ context: childPanelReadyContext })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._registerChildPanelReady?.(this._resolveVisiblePages());
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
}).then((bluetoothEntries) => {
|
||||
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages = (this._visiblePages ?? []).map((page) => ({
|
||||
const pages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
}).map((page) => ({
|
||||
...page,
|
||||
name:
|
||||
page.name ||
|
||||
@@ -79,20 +75,6 @@ class HaConfigNavigation extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _resolveVisiblePages(): Promise<void> {
|
||||
if (this.pages.some((page) => page.component === "bluetooth")) {
|
||||
const entries = await getConfigEntries(this.hass, {
|
||||
domain: "bluetooth",
|
||||
});
|
||||
this._hasBluetoothConfigEntries = entries.length > 0;
|
||||
}
|
||||
|
||||
this._visiblePages = filterNavigationPages(this.hass, this.pages, {
|
||||
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
|
||||
});
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
@@ -29,10 +29,11 @@ import "./ha-energy-power-config";
|
||||
import {
|
||||
buildPowerExcludeList,
|
||||
getInitialPowerConfig,
|
||||
getPowerHelperEntityId,
|
||||
getPowerTypeFromConfig,
|
||||
type HaEnergyPowerConfig,
|
||||
isPowerConfigValid,
|
||||
type PowerType,
|
||||
} from "./ha-energy-power-config";
|
||||
} from "./power-config";
|
||||
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
|
||||
@@ -67,8 +68,6 @@ export class DialogEnergyBatterySettings
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
@@ -229,6 +228,10 @@ export class DialogEnergyBatterySettings
|
||||
.powerType=${this._powerType}
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this._params.source,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
@@ -295,11 +298,7 @@ export class DialogEnergyBatterySettings
|
||||
}
|
||||
|
||||
// Check power config validity
|
||||
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return isPowerConfigValid(this._powerType, this._powerConfig);
|
||||
}
|
||||
|
||||
private async _updateMetadata(statId: string) {
|
||||
@@ -374,6 +373,7 @@ export class DialogEnergyBatterySettings
|
||||
if (this._source.capacity === undefined) {
|
||||
delete this._source.capacity;
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
@@ -33,10 +33,11 @@ import "./ha-energy-power-config";
|
||||
import {
|
||||
buildPowerExcludeList,
|
||||
getInitialPowerConfig,
|
||||
getPowerHelperEntityId,
|
||||
getPowerTypeFromConfig,
|
||||
type HaEnergyPowerConfig,
|
||||
isPowerConfigValid,
|
||||
type PowerType,
|
||||
} from "./ha-energy-power-config";
|
||||
} from "./power-config";
|
||||
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
|
||||
@@ -77,8 +78,6 @@ export class DialogEnergyGridSettings
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
@@ -471,6 +470,10 @@ export class DialogEnergyGridSettings
|
||||
.powerType=${this._powerType}
|
||||
.powerConfig=${this._powerConfig}
|
||||
.excludeList=${this._excludeListPower}
|
||||
.helperEntityId=${getPowerHelperEntityId(
|
||||
this._params.source,
|
||||
this._powerConfig
|
||||
)}
|
||||
.localizeBaseKey=${"ui.panel.config.energy.grid.dialog"}
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
@@ -508,10 +511,8 @@ export class DialogEnergyGridSettings
|
||||
}
|
||||
|
||||
// Check power config validity (if power is configured)
|
||||
if (hasPower) {
|
||||
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
|
||||
return false;
|
||||
}
|
||||
if (hasPower && !isPowerConfigValid(this._powerType, this._powerConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,99 +1,24 @@
|
||||
import { mdiInformationOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import { buttonLinkStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
import type { PowerType } from "./power-config";
|
||||
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
/**
|
||||
* Extracts the power type from a PowerConfig object.
|
||||
*/
|
||||
export function getPowerTypeFromConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerType {
|
||||
if (powerConfig) {
|
||||
if (powerConfig.stat_rate_inverted) {
|
||||
return "inverted";
|
||||
}
|
||||
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
|
||||
return "two_sensors";
|
||||
}
|
||||
if (powerConfig.stat_rate) {
|
||||
return "standard";
|
||||
}
|
||||
} else if (statRate) {
|
||||
// Legacy format - treat as standard
|
||||
return "standard";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initial PowerConfig from existing config or legacy stat_rate.
|
||||
*/
|
||||
export function getInitialPowerConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerConfig {
|
||||
if (powerConfig) {
|
||||
return { ...powerConfig };
|
||||
}
|
||||
if (statRate) {
|
||||
return { stat_rate: statRate };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
const powerIds: string[] = [];
|
||||
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentPowerIds = [
|
||||
currentPowerConfig.stat_rate,
|
||||
currentPowerConfig.stat_rate_inverted,
|
||||
currentPowerConfig.stat_rate_from,
|
||||
currentPowerConfig.stat_rate_to,
|
||||
currentStatRate,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return powerIds.filter((id) => !currentPowerIds.includes(id));
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
|
||||
@@ -110,10 +35,14 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public excludeList?: string[];
|
||||
|
||||
/** Entity id of the power sensor generated for the saved config, if any. */
|
||||
@property({ attribute: false }) public helperEntityId?: string;
|
||||
|
||||
/**
|
||||
* Base key for localization lookups.
|
||||
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
|
||||
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
|
||||
* Should include keys for: sensor_type, sensor_type_para, type_none, type_standard,
|
||||
* type_inverted, type_two_sensors, power, power_helper, type_inverted_description,
|
||||
* power_from, power_to, helper_sensor_note, helper_sensor_in_use
|
||||
*/
|
||||
@property({ attribute: false }) public localizeBaseKey =
|
||||
"ui.panel.config.energy.battery.dialog";
|
||||
@@ -164,11 +93,13 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("inverted")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="two_sensors">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("two_sensors")}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
|
||||
@@ -243,9 +174,50 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderHelperSensorInUse()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorNote(powerType: PowerType) {
|
||||
const id = `helper-sensor-note-${powerType}`;
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
id=${id}
|
||||
tabindex="0"
|
||||
class="note-icon"
|
||||
.path=${mdiInformationOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip .for=${id} placement="top">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorInUse() {
|
||||
if (!this.helperEntityId) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<p class="helper-sensor-in-use">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
|
||||
{
|
||||
entity: html`<button class="link" @click=${this._showHelperSensor}>
|
||||
${this.helperEntityId}
|
||||
</button>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private _showHelperSensor() {
|
||||
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
|
||||
}
|
||||
|
||||
private _handlePowerTypeChanged(ev: Event) {
|
||||
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
|
||||
// Clear power config when switching types
|
||||
@@ -289,48 +261,46 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the power config is complete for the selected type.
|
||||
*/
|
||||
public isValid(): boolean {
|
||||
switch (this.powerType) {
|
||||
case "none":
|
||||
return true;
|
||||
case "standard":
|
||||
return !!this.powerConfig.stat_rate;
|
||||
case "inverted":
|
||||
return !!this.powerConfig.stat_rate_inverted;
|
||||
case "two_sensors":
|
||||
return (
|
||||
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
`;
|
||||
static readonly styles: CSSResultGroup = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.note-icon {
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.helper-sensor-in-use {
|
||||
margin: var(--ha-space-2) 0 0 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.helper-sensor-in-use button.link {
|
||||
color: var(--primary-color);
|
||||
/* entity ids offer no break opportunities of their own */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
|
||||
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
|
||||
|
||||
/**
|
||||
* Extracts the power type from a PowerConfig object.
|
||||
*/
|
||||
export function getPowerTypeFromConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerType {
|
||||
if (powerConfig) {
|
||||
if (powerConfig.stat_rate_inverted) {
|
||||
return "inverted";
|
||||
}
|
||||
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
|
||||
return "two_sensors";
|
||||
}
|
||||
if (powerConfig.stat_rate) {
|
||||
return "standard";
|
||||
}
|
||||
} else if (statRate) {
|
||||
// Legacy format - treat as standard
|
||||
return "standard";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initial PowerConfig from existing config or legacy stat_rate.
|
||||
*/
|
||||
export function getInitialPowerConfig(
|
||||
powerConfig?: PowerConfig,
|
||||
statRate?: string
|
||||
): PowerConfig {
|
||||
if (powerConfig) {
|
||||
return { ...powerConfig };
|
||||
}
|
||||
if (statRate) {
|
||||
return { stat_rate: statRate };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the power config is complete for the selected power type.
|
||||
*/
|
||||
export function isPowerConfigValid(
|
||||
powerType: PowerType,
|
||||
powerConfig: PowerConfig
|
||||
): boolean {
|
||||
switch (powerType) {
|
||||
case "none":
|
||||
return true;
|
||||
case "standard":
|
||||
return !!powerConfig.stat_rate;
|
||||
case "inverted":
|
||||
return !!powerConfig.stat_rate_inverted;
|
||||
case "two_sensors":
|
||||
return !!powerConfig.stat_rate_from && !!powerConfig.stat_rate_to;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an exclude list for power statistics from existing sources.
|
||||
*/
|
||||
export function buildPowerExcludeList(
|
||||
sources: { stat_rate?: string; power_config?: PowerConfig }[],
|
||||
currentPowerConfig: PowerConfig,
|
||||
currentStatRate?: string
|
||||
): string[] {
|
||||
const powerIds: string[] = [];
|
||||
|
||||
sources.forEach((entry) => {
|
||||
if (entry.stat_rate) powerIds.push(entry.stat_rate);
|
||||
if (entry.power_config) {
|
||||
if (entry.power_config.stat_rate) {
|
||||
powerIds.push(entry.power_config.stat_rate);
|
||||
}
|
||||
if (entry.power_config.stat_rate_inverted) {
|
||||
powerIds.push(entry.power_config.stat_rate_inverted);
|
||||
}
|
||||
if (entry.power_config.stat_rate_from) {
|
||||
powerIds.push(entry.power_config.stat_rate_from);
|
||||
}
|
||||
if (entry.power_config.stat_rate_to) {
|
||||
powerIds.push(entry.power_config.stat_rate_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentPowerIds = [
|
||||
currentPowerConfig.stat_rate,
|
||||
currentPowerConfig.stat_rate_inverted,
|
||||
currentPowerConfig.stat_rate_from,
|
||||
currentPowerConfig.stat_rate_to,
|
||||
currentStatRate,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return powerIds.filter((id) => !currentPowerIds.includes(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entity id of the power sensor the backend generated for a saved
|
||||
* source, if any. Inverted and two sensor configs get such a helper, its entity
|
||||
* id is stored in `stat_rate`. Returns nothing while the config differs from the
|
||||
* saved one, as the helper doesn't match the edited config yet.
|
||||
*/
|
||||
export function getPowerHelperEntityId(
|
||||
source: { stat_rate?: string; power_config?: PowerConfig } | undefined,
|
||||
currentPowerConfig: PowerConfig
|
||||
): string | undefined {
|
||||
if (!source?.stat_rate || !source.power_config) {
|
||||
return undefined;
|
||||
}
|
||||
const savedPowerType = getPowerTypeFromConfig(source.power_config);
|
||||
if (savedPowerType !== "inverted" && savedPowerType !== "two_sensors") {
|
||||
return undefined;
|
||||
}
|
||||
if (!deepEqual(source.power_config, currentPowerConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
return source.stat_rate;
|
||||
}
|
||||
@@ -89,7 +89,6 @@ class HaPanelConfig extends HassRouterPage {
|
||||
dashboard: {
|
||||
tag: "ha-config-dashboard",
|
||||
load: () => import("./dashboard/ha-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
entities: {
|
||||
tag: "ha-config-entities",
|
||||
|
||||
+115
-6
@@ -4,6 +4,7 @@ import {
|
||||
mdiCloseCircle,
|
||||
mdiProgressClock,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -25,10 +26,10 @@ import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type {
|
||||
ZWaveJSNodeCapabilities,
|
||||
ZWaveJSNodeConfigParam,
|
||||
ZWaveJSNodeConfigParams,
|
||||
ZWaveJSSetConfigParamResult,
|
||||
ZwaveJSNodeConfigParameterUpdate,
|
||||
ZwaveJSNodeMetadata,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
fetchZwaveNodeMetadata,
|
||||
invokeZWaveCCApi,
|
||||
setZwaveNodeConfigParameter,
|
||||
subscribeZwaveNodeConfigParameterUpdates,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../../layouts/hass-error-screen";
|
||||
@@ -59,7 +61,7 @@ const icons = {
|
||||
|
||||
@customElement("zwave_js-node-config")
|
||||
class ZWaveJSNodeConfig extends LitElement {
|
||||
public hass!: HomeAssistant;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@@ -83,13 +85,41 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
|
||||
@state() private _resetDialogProgress = false;
|
||||
|
||||
private _unsubConfigParamUpdates?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _resultTimeouts: Record<string, number> = {};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.deviceId = this.route.path.substr(1);
|
||||
this._subscribeConfigParameterUpdates();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeConfigParameterUpdates();
|
||||
this._clearAllResultTimeouts();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!changedProps.has("route") || !this.route) {
|
||||
return;
|
||||
}
|
||||
const deviceId = this.route.path.slice(1);
|
||||
if (deviceId !== this.deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
this._config = undefined;
|
||||
this._clearAllResultTimeouts();
|
||||
this._results = {};
|
||||
this._error = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
if (!this._config || changedProps.has("deviceId")) {
|
||||
if (changedProps.has("deviceId")) {
|
||||
this._fetchData();
|
||||
this._subscribeConfigParameterUpdates();
|
||||
} else if (!this._config) {
|
||||
this._fetchData();
|
||||
}
|
||||
}
|
||||
@@ -443,6 +473,58 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
<p>${item.value}</p>`;
|
||||
}
|
||||
|
||||
private _subscribeConfigParameterUpdates(): void {
|
||||
this._unsubscribeConfigParameterUpdates();
|
||||
if (!this.isConnected || !this.hass || !this.deviceId) {
|
||||
return;
|
||||
}
|
||||
this._unsubConfigParamUpdates = subscribeZwaveNodeConfigParameterUpdates(
|
||||
this.hass,
|
||||
this.deviceId,
|
||||
this._handleConfigParameterUpdate
|
||||
);
|
||||
this._unsubConfigParamUpdates.catch(() => {
|
||||
// The backend doesn't support the subscription; the page still works,
|
||||
// it just won't receive live updates
|
||||
this._unsubConfigParamUpdates = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private _unsubscribeConfigParameterUpdates(): void {
|
||||
if (this._unsubConfigParamUpdates) {
|
||||
this._unsubConfigParamUpdates
|
||||
.then((unsub) => unsub())
|
||||
.catch(() => {
|
||||
// The subscription never succeeded, so there is nothing to clean up
|
||||
});
|
||||
this._unsubConfigParamUpdates = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleConfigParameterUpdate = (
|
||||
update: ZwaveJSNodeConfigParameterUpdate
|
||||
): void => {
|
||||
const param = this._config?.[update.id];
|
||||
if (!param) {
|
||||
return;
|
||||
}
|
||||
const status = this._results[update.id]?.status;
|
||||
if (status === "queued") {
|
||||
// The device applied the queued change; the accepted result is cleared
|
||||
// after a short delay by _setResult so the success message shows.
|
||||
// The value was already set optimistically when the change was queued,
|
||||
// so this runs even if the reported value matches the stored one.
|
||||
this._setResult(update.id, "accepted");
|
||||
} else if (status === "error" && param.value !== update.value) {
|
||||
// The parameter changed, so the previous error no longer applies
|
||||
this._setResult(update.id, undefined);
|
||||
}
|
||||
if (param.value !== update.value) {
|
||||
param.value = update.value;
|
||||
this._config = { ...this._config };
|
||||
}
|
||||
};
|
||||
|
||||
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): boolean {
|
||||
// Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled
|
||||
// We want those to be considered boolean and show a toggle switch
|
||||
@@ -609,16 +691,38 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _clearResultTimeout(key: string): void {
|
||||
if (key in this._resultTimeouts) {
|
||||
clearTimeout(this._resultTimeouts[key]);
|
||||
delete this._resultTimeouts[key];
|
||||
}
|
||||
}
|
||||
|
||||
private _clearAllResultTimeouts(): void {
|
||||
Object.values(this._resultTimeouts).forEach((timeout) =>
|
||||
clearTimeout(timeout)
|
||||
);
|
||||
this._resultTimeouts = {};
|
||||
}
|
||||
|
||||
private _setResult(key: string, value: string | undefined) {
|
||||
this._clearResultTimeout(key);
|
||||
if (value === undefined) {
|
||||
delete this._results[key];
|
||||
this.requestUpdate();
|
||||
} else {
|
||||
this._results = { ...this._results, [key]: { status: value } };
|
||||
if (value === "accepted") {
|
||||
// Show the success message briefly, then clear it
|
||||
this._resultTimeouts[key] = window.setTimeout(() => {
|
||||
this._setResult(key, undefined);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _setError(key: string, message: string) {
|
||||
this._clearResultTimeout(key);
|
||||
const errorParam = { status: "error", error: message };
|
||||
this._results = { ...this._results, [key]: errorParam };
|
||||
}
|
||||
@@ -634,12 +738,17 @@ class ZWaveJSNodeConfig extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let capabilities: ZWaveJSNodeCapabilities | undefined;
|
||||
[this._nodeMetadata, this._config, capabilities] = await Promise.all([
|
||||
const [nodeMetadata, config, capabilities] = await Promise.all([
|
||||
fetchZwaveNodeMetadata(this.hass, device.id),
|
||||
fetchZwaveNodeConfigParameters(this.hass, device.id),
|
||||
fetchZwaveNodeCapabilities(this.hass, device.id),
|
||||
]);
|
||||
if (device.id !== this.deviceId) {
|
||||
// The user navigated to another node while the data was loading
|
||||
return;
|
||||
}
|
||||
this._nodeMetadata = nodeMetadata;
|
||||
this._config = config;
|
||||
this._canResetAll =
|
||||
capabilities &&
|
||||
Object.values(capabilities).some((endpoint) =>
|
||||
|
||||
@@ -397,8 +397,20 @@ class HaConfigHttpForm extends LitElement {
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
this._showNoChanges = false;
|
||||
// Drop empty entries from multi-value fields, and omit the field entirely
|
||||
// once it is empty so the backend applies its default. Otherwise a cleared
|
||||
// "IP address to bind to" would submit [""] / [], which binds to nothing.
|
||||
const config = Object.fromEntries(
|
||||
Object.entries(this._config).map(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const filtered = value.filter(Boolean);
|
||||
return [key, filtered.length ? filtered : undefined];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
) as HttpConfig;
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
const result = await saveHttpConfig(this.hass, config);
|
||||
if (!result.restart) {
|
||||
this._showNoChanges = true;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
mdiRestore,
|
||||
mdiTrashCanOutline,
|
||||
mdiViewSplitHorizontal,
|
||||
mdiViewSplitVertical,
|
||||
} from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-code-editor";
|
||||
import "../../../../components/ha-expansion-panel";
|
||||
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
|
||||
import "../../../../components/ha-label";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-split-panel";
|
||||
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tip";
|
||||
import type { RenderTemplateResult } from "../../../../data/ws-templates";
|
||||
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
|
||||
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
|
||||
{ key: "docs_functions", path: "/template-functions/" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
|
||||
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
|
||||
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
|
||||
const DEFAULT_SPLIT_POSITION = 50;
|
||||
|
||||
type SplitOrientation = "horizontal" | "vertical";
|
||||
|
||||
@customElement("tools-template")
|
||||
class HaPanelDevTemplate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
|
||||
|
||||
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@state() private _descriptionExpanded = false;
|
||||
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
|
||||
|
||||
@query("ha-tip") private _editorTip?: HTMLElement;
|
||||
@state() private _splitOrientation: SplitOrientation = "horizontal";
|
||||
|
||||
private _template = "";
|
||||
|
||||
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
|
||||
// its late-arriving results discarded.
|
||||
private _subscribeRequestId = 0;
|
||||
|
||||
private _tipResizeObserver?: ResizeObserver;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._template && !this._unsubRenderTemplate) {
|
||||
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeTemplate();
|
||||
this._tipResizeObserver?.disconnect();
|
||||
this._tipResizeObserver = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (localStorage && localStorage["panel-dev-template-template"]) {
|
||||
this._template = localStorage["panel-dev-template-template"];
|
||||
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
|
||||
this._template = localStorage[STORAGE_KEY_TEMPLATE];
|
||||
} else {
|
||||
this._template = DEMO_TEMPLATE;
|
||||
}
|
||||
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
|
||||
if (storedPosition) {
|
||||
const parsed = parseFloat(storedPosition);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
||||
this._splitPosition = parsed;
|
||||
}
|
||||
}
|
||||
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
|
||||
this._splitOrientation = "vertical";
|
||||
}
|
||||
this._subscribeTemplate();
|
||||
this._observeTipHeight();
|
||||
this._inited = true;
|
||||
}
|
||||
|
||||
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
|
||||
: "dict"
|
||||
: type;
|
||||
|
||||
const editorCard = this._renderEditorCard();
|
||||
const resultCard = this._renderResultCard(type, resultType);
|
||||
|
||||
// On narrow viewports side-by-side is too cramped, so force the (still
|
||||
// resizable) stacked layout and hide the orientation toggle.
|
||||
const orientation = this.narrow ? "vertical" : this._splitOrientation;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="about">
|
||||
<ha-expansion-panel
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.about"
|
||||
)}
|
||||
outlined
|
||||
.expanded=${this._descriptionExpanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div class="description">
|
||||
<p>
|
||||
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
layout: !this.narrow,
|
||||
horizontal: !this.narrow,
|
||||
})}"
|
||||
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
|
||||
>
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._restoreDemo}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.reset"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._clear}>
|
||||
${this.hass.localize("ui.common.clear")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._templateResult
|
||||
? html`<pre
|
||||
class="rendered ${classMap({
|
||||
[resultType]: resultType,
|
||||
})}"
|
||||
>
|
||||
<ha-split-panel
|
||||
class="panes ${orientation === "vertical" ? "vertical" : ""}"
|
||||
.position=${this._splitPosition}
|
||||
.orientation=${orientation}
|
||||
snap="50%"
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
>
|
||||
<div slot="start" class="pane">${editorCard}</div>
|
||||
<div slot="end" class="pane">${resultCard}</div>
|
||||
${this.narrow ? nothing : this._renderOrientationToggle()}
|
||||
</ha-split-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOrientationToggle() {
|
||||
const label = this.hass.localize(
|
||||
this._splitOrientation === "vertical"
|
||||
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
|
||||
: "ui.panel.config.tools.tabs.templates.layout_stacked"
|
||||
);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
slot="divider"
|
||||
class="divider-toggle"
|
||||
.title=${label}
|
||||
aria-label=${label}
|
||||
@mousedown=${stopPropagation}
|
||||
@touchstart=${stopPropagation}
|
||||
@click=${this._toggleOrientation}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this._splitOrientation === "vertical"
|
||||
? mdiViewSplitVertical
|
||||
: mdiViewSplitHorizontal
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
|
||||
// copy, search and fullscreen buttons; the trailing divider separates them.
|
||||
private _editorToolbarItems = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"]
|
||||
): (HaIconButtonToolbarItem | string)[] => [
|
||||
{
|
||||
id: "restore-demo",
|
||||
label: localize("ui.panel.config.tools.tabs.templates.reset"),
|
||||
path: mdiRestore,
|
||||
action: () => this._restoreDemo(),
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: localize("ui.common.clear"),
|
||||
path: mdiTrashCanOutline,
|
||||
action: () => this._clear(),
|
||||
},
|
||||
"divider",
|
||||
]
|
||||
);
|
||||
|
||||
private _renderEditorCard() {
|
||||
return html`
|
||||
<ha-card
|
||||
class="edit-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.editor"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.value=${this._template}
|
||||
.error=${this._error}
|
||||
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
@value-changed=${this._templateChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
${
|
||||
this.narrow
|
||||
? nothing
|
||||
: html`
|
||||
<ha-tip>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.keyboard_tip",
|
||||
{
|
||||
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
|
||||
}
|
||||
)}
|
||||
</ha-tip>
|
||||
`
|
||||
}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderResultCard(type: string, resultType: string) {
|
||||
const showEmptyState =
|
||||
!this._error && !this._rendering && !this._template?.trim();
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class="render-pane"
|
||||
header=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result"
|
||||
)}
|
||||
>
|
||||
<div class="card-content ha-scrollbar">
|
||||
${
|
||||
this._rendering
|
||||
? html`<ha-spinner
|
||||
class="render-spinner"
|
||||
size="small"
|
||||
></ha-spinner>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert
|
||||
alert-type=${this._errorLevel?.toLowerCase() || "error"}
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showEmptyState
|
||||
? html`<div class="empty">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_placeholder"
|
||||
)}
|
||||
</div>`
|
||||
: this._templateResult
|
||||
? html`
|
||||
<ha-label dense>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</ha-label>
|
||||
<pre class="rendered">
|
||||
${
|
||||
type === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result
|
||||
}</pre>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</p>
|
||||
${
|
||||
this._templateResult.listeners.time
|
||||
? html`
|
||||
@@ -316,109 +410,179 @@ ${
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _observeTipHeight() {
|
||||
if (!this._editorTip || this._tipResizeObserver) {
|
||||
return;
|
||||
}
|
||||
this._tipResizeObserver = new ResizeObserver((entries) => {
|
||||
const height =
|
||||
entries[0]?.borderBoxSize?.[0]?.blockSize ??
|
||||
entries[0]?.contentRect.height;
|
||||
if (height) {
|
||||
this.style.setProperty("--tip-height", `${height}px`);
|
||||
}
|
||||
});
|
||||
this._tipResizeObserver.observe(this._editorTip);
|
||||
private _splitRepositioned(ev: Event) {
|
||||
this._splitPosition = (ev.target as HaSplitPanel).position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
private _expandedChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
|
||||
) {
|
||||
this._descriptionExpanded = ev.detail.expanded;
|
||||
private _toggleOrientation() {
|
||||
this._splitOrientation =
|
||||
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
|
||||
if (this._inited) {
|
||||
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
private _storeSplitPosition = debounce(
|
||||
() => {
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-4);
|
||||
.about {
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.content:has(ha-expansion-panel) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content.horizontal {
|
||||
--panel-header-height: calc(
|
||||
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
|
||||
var(--ha-font-size-m) + 1px + 2px
|
||||
);
|
||||
--description-pane-height: calc(
|
||||
var(--ha-space-4) + 48px +
|
||||
(
|
||||
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
|
||||
var(--ha-space-1) * 2
|
||||
) *
|
||||
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
|
||||
);
|
||||
--card-header-height: calc(
|
||||
var(--ha-space-3) + var(--ha-space-4) +
|
||||
var(--ha-line-height-expanded) *
|
||||
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
|
||||
);
|
||||
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
|
||||
--tip-height-minimal: calc(
|
||||
var(--mdc-icon-size, 24px) + var(--ha-space-4)
|
||||
);
|
||||
--edit-pane-height: calc(
|
||||
100vh - var(--panel-header-height) - var(
|
||||
--description-pane-height
|
||||
) - var(--ha-space-4) *
|
||||
2
|
||||
);
|
||||
--code-mirror-max-height: calc(
|
||||
var(--edit-pane-height) - var(--card-header-height) +
|
||||
var(--ha-space-2) - var(--card-actions-height) - var(
|
||||
--tip-height,
|
||||
var(--tip-height-minimal)
|
||||
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
|
||||
2
|
||||
);
|
||||
.about a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-4);
|
||||
--ha-split-panel-min: 20%;
|
||||
--ha-split-panel-max: 80%;
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* On wide viewports we slot our own handle (the orientation toggle)
|
||||
into the divider, so hide the default grip. On narrow there is no
|
||||
toggle, so keep the default grip as the resize affordance. */
|
||||
:host(:not([narrow])) .panes {
|
||||
--ha-split-panel-grip-display: none;
|
||||
}
|
||||
|
||||
/* Orientation toggle that lives on the divider. Clicking it toggles
|
||||
orientation; resizing is done by dragging the divider elsewhere. */
|
||||
.divider-toggle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 16px;
|
||||
transition:
|
||||
color var(--ha-animation-duration-fast) ease-out,
|
||||
border-color var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.divider-toggle:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.divider-toggle:focus-visible {
|
||||
outline: none;
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.pane[slot="start"] {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane[slot="end"] {
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="start"] {
|
||||
padding-inline-end: 0;
|
||||
padding-block-end: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.panes.vertical .pane[slot="end"] {
|
||||
padding-inline-start: 0;
|
||||
padding-block-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.pane ha-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-pane ha-code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
--code-mirror-height: 100%;
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.edit-pane {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.edit-pane a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content.horizontal > * {
|
||||
width: 50%;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.render-spinner {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
@@ -428,10 +592,24 @@ ${
|
||||
}
|
||||
|
||||
ha-alert {
|
||||
margin-bottom: var(--ha-space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.render-pane ha-label {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
padding: var(--ha-space-4);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.rendered {
|
||||
font-family: var(--ha-font-family-code);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
@@ -439,6 +617,7 @@ ${
|
||||
clear: both;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-2);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
@@ -447,7 +626,7 @@ ${
|
||||
|
||||
p,
|
||||
ul {
|
||||
margin-block-end: 0;
|
||||
margin-block: 0;
|
||||
}
|
||||
.description > p {
|
||||
margin-block-start: 0;
|
||||
@@ -468,26 +647,6 @@ ${
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane .card-content {
|
||||
overflow: auto;
|
||||
max-height: calc(
|
||||
var(--code-mirror-max-height) +
|
||||
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
|
||||
);
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane {
|
||||
overflow: hidden;
|
||||
padding-bottom: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
}
|
||||
|
||||
.all_listeners {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
@@ -507,19 +666,6 @@ ${
|
||||
background-color: var(--secondary-background-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
.content ha-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
}
|
||||
.card-actions > ha-button:last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -615,7 +761,7 @@ ${
|
||||
if (!this._inited) {
|
||||
return;
|
||||
}
|
||||
localStorage["panel-dev-template-template"] = this._template;
|
||||
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
|
||||
}
|
||||
|
||||
private async _restoreDemo() {
|
||||
@@ -631,7 +777,7 @@ ${
|
||||
}
|
||||
this._template = DEMO_TEMPLATE;
|
||||
this._subscribeTemplate();
|
||||
delete localStorage["panel-dev-template-template"];
|
||||
delete localStorage[STORAGE_KEY_TEMPLATE];
|
||||
}
|
||||
|
||||
private async _clear() {
|
||||
@@ -647,12 +793,8 @@ ${
|
||||
}
|
||||
this._unsubscribeTemplate();
|
||||
this._template = "";
|
||||
// Reset to empty result. Setting to 'undefined' results in a different visual
|
||||
// behaviour compared to manually emptying the template input box.
|
||||
this._templateResult = {
|
||||
result: "",
|
||||
listeners: { all: false, entities: [], domains: [], time: false },
|
||||
};
|
||||
// An empty template shows the placeholder empty state.
|
||||
this._templateResult = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-menu-button";
|
||||
@@ -161,7 +162,9 @@ class PanelEnergy extends LitElement {
|
||||
.route=${this.route}
|
||||
.panel=${this.panel}
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
.backPath=${this._searchParms.get("backPath") || "/"}
|
||||
.backPath=${
|
||||
sanitizeNavigationPath(this._searchParms.get("backPath")) || "/"
|
||||
}
|
||||
@reload-energy-panel=${this._reloadConfig}
|
||||
>
|
||||
</hui-root>
|
||||
|
||||
@@ -130,6 +130,19 @@ class PanelHome extends LitElement {
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
void import("../lovelace/strategies/home/home-dashboard-strategy").catch(
|
||||
() => undefined
|
||||
);
|
||||
void import("../lovelace/strategies/home/home-overview-view-strategy")
|
||||
.then(({ preloadHomeEnergyPreferences }) => {
|
||||
const path = this.route?.path?.split("/")[1];
|
||||
return !path || path === "overview"
|
||||
? preloadHomeEnergyPreferences(this.hass)
|
||||
: undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
void import("../lovelace/views/hui-sections-view").catch(() => undefined);
|
||||
|
||||
this._updateExtraActionItems();
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await this._loadConfigPromise;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -32,8 +31,6 @@ class PanelLight extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -130,7 +127,6 @@ class PanelLight extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
"--tile-color": color,
|
||||
};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
${
|
||||
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${Boolean(this._hasCardAction)}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info
|
||||
slot="info"
|
||||
class=${ifDefined(
|
||||
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
|
||||
)}
|
||||
.primary=${primary}
|
||||
.secondary=${secondary}
|
||||
></ha-tile-info>
|
||||
@@ -818,6 +825,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
ha-tile-info.twoline {
|
||||
--ha-tile-info-primary-line-clamp: 2;
|
||||
--ha-tile-info-primary-line-height: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: ShortcutCardConfig;
|
||||
|
||||
private _navInfo = new NavigationPathInfoController(this);
|
||||
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const style = color ? { "--tile-color": color } : {};
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
<ha-tile-container
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/animation/ha-fade-in";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -39,9 +38,7 @@ export class HuiStartingCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner></ha-spinner>
|
||||
</ha-fade-in>
|
||||
<ha-spinner></ha-spinner>
|
||||
${this.hass.localize("ui.panel.lovelace.cards.starting.description")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@state() private _config?: TileCardConfig;
|
||||
|
||||
@state() private _featureContext: LovelaceCardFeatureContext = {};
|
||||
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const hasImage = Boolean(imageUrl);
|
||||
|
||||
const fixedInfoHeight =
|
||||
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
|
||||
<ha-tile-container
|
||||
.featurePosition=${featurePosition}
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.fixedInfoHeight=${fixedInfoHeight}
|
||||
.interactive=${this._hasCardAction}
|
||||
.actionHandlerOptions=${{
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
|
||||
@@ -31,6 +31,7 @@ import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
import {
|
||||
addSearchParam,
|
||||
extractSearchParamsObject,
|
||||
@@ -75,7 +76,6 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
|
||||
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
@@ -164,8 +164,6 @@ class HUIRoot extends LitElement {
|
||||
|
||||
private _restoreScroll = false;
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
|
||||
apply: (config) => this._applyUndoRedo(config),
|
||||
currentConfig: () => ({
|
||||
@@ -779,7 +777,7 @@ class HUIRoot extends LitElement {
|
||||
huiView.narrow = this.narrow;
|
||||
}
|
||||
|
||||
let newSelectView: HUIRoot["_curView"];
|
||||
let newSelectView;
|
||||
|
||||
let viewPath: string | undefined = this.route!.path.split("/")[1];
|
||||
viewPath = viewPath ? decodeURI(viewPath) : undefined;
|
||||
@@ -896,10 +894,12 @@ class HUIRoot extends LitElement {
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
if (curViewConfig?.back_path != null) {
|
||||
navigate(curViewConfig.back_path, { replace: true });
|
||||
} else if (this.backPath) {
|
||||
navigate(this.backPath, { replace: true });
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
navigate(backPath, { replace: true });
|
||||
} else if (history.length > 1) {
|
||||
goBack();
|
||||
} else if (!views[0].subview) {
|
||||
@@ -921,11 +921,12 @@ class HUIRoot extends LitElement {
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
if (curViewConfig?.back_path != null) {
|
||||
return curViewConfig.back_path;
|
||||
}
|
||||
if (this.backPath) {
|
||||
return this.backPath;
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
return backPath;
|
||||
}
|
||||
return curViewConfig?.subview ? this.route!.prefix : undefined;
|
||||
}
|
||||
@@ -1257,7 +1258,7 @@ class HUIRoot extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let view: HUIView;
|
||||
let view;
|
||||
const viewConfig = this.config.views[viewIndex];
|
||||
|
||||
if (!viewConfig) {
|
||||
@@ -1268,16 +1269,12 @@ class HUIRoot extends LitElement {
|
||||
if (this._viewCache[viewIndex]) {
|
||||
view = this._viewCache[viewIndex];
|
||||
} else {
|
||||
if (!this._childPanelReady) {
|
||||
this._childPanelReady = new ChildPanelReady(this);
|
||||
this.requestUpdate();
|
||||
}
|
||||
view = document.createElement("hui-view");
|
||||
view.index = viewIndex;
|
||||
this._viewCache[viewIndex] = view;
|
||||
}
|
||||
|
||||
view.lovelace = this.lovelace!;
|
||||
view.lovelace = this.lovelace;
|
||||
view.hass = this.hass;
|
||||
view.narrow = this.narrow;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../../../common/entity/entity_filter";
|
||||
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
|
||||
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
|
||||
import type { EnergyPreferences } from "../../../../data/energy";
|
||||
import { getEnergyPreferences } from "../../../../data/energy";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type {
|
||||
@@ -50,6 +51,26 @@ export interface HomeOverviewViewStrategyConfig {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
const energyPreferencesPromises = new WeakMap<
|
||||
HomeAssistant["connection"],
|
||||
Promise<EnergyPreferences | undefined>
|
||||
>();
|
||||
|
||||
export const preloadHomeEnergyPreferences = (hass: HomeAssistant) => {
|
||||
if (!isComponentLoaded(hass.config, "energy")) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const existing = energyPreferencesPromises.get(hass.connection);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = getEnergyPreferences(hass).catch(() => undefined);
|
||||
energyPreferencesPromises.set(hass.connection, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const computeAreaCard = (
|
||||
areaId: string,
|
||||
hass: HomeAssistant
|
||||
@@ -305,10 +326,8 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
.filter(weatherFilter)
|
||||
.sort()[0];
|
||||
|
||||
const energyPrefs = isComponentLoaded(hass.config, "energy")
|
||||
? // It raises if not configured, just swallow that.
|
||||
await getEnergyPreferences(hass).catch(() => undefined)
|
||||
: undefined;
|
||||
const energyPrefs = await preloadHomeEnergyPreferences(hass);
|
||||
energyPreferencesPromises.delete(hass.connection);
|
||||
|
||||
const hasEnergy =
|
||||
hass.panels.energy &&
|
||||
|
||||
@@ -58,12 +58,6 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
private _mqlListenerRef?: () => void;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
public initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initMqls();
|
||||
@@ -172,13 +166,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
root.removeChild(root.lastChild);
|
||||
}
|
||||
|
||||
columns.forEach((column) => {
|
||||
root.appendChild(column);
|
||||
});
|
||||
if (this.cards.length === 0 || columns.some((column) => column.lastChild)) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
columns.forEach((column) => root.appendChild(column));
|
||||
}
|
||||
|
||||
private async _createColumns() {
|
||||
@@ -246,10 +234,6 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
|
||||
index,
|
||||
this.lovelace!.editMode
|
||||
);
|
||||
if (columnElements.some((column) => column.isConnected)) {
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty columns
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -20,10 +19,6 @@ import type {
|
||||
} from "../../../data/lovelace/config/view";
|
||||
import { isStrategyView } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
childPanelReadyContext,
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import "../badges/hui-badge";
|
||||
import type { HuiBadge } from "../badges/hui-badge";
|
||||
import "../cards/hui-card";
|
||||
@@ -100,15 +95,6 @@ export class HUIView extends ReactiveElement {
|
||||
|
||||
private _config?: LovelaceViewConfig;
|
||||
|
||||
private _resolveInitialRender?: () => void;
|
||||
|
||||
private _initialRenderComplete = new Promise<void>((resolve) => {
|
||||
this._resolveInitialRender = resolve;
|
||||
});
|
||||
|
||||
@consume({ context: childPanelReadyContext })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
@storage({
|
||||
key: "dashboardCardClipboard",
|
||||
state: false,
|
||||
@@ -166,11 +152,6 @@ export class HUIView extends ReactiveElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._registerChildPanelReady?.(this._initialRenderComplete);
|
||||
}
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
@@ -343,13 +324,6 @@ export class HUIView extends ReactiveElement {
|
||||
const viewConfig = await this._generateConfig(rawConfig);
|
||||
|
||||
this._setConfig(viewConfig, isStrategy);
|
||||
await customElements.whenDefined(this._layoutElement!.localName);
|
||||
if (this._layoutElement instanceof ReactiveElement) {
|
||||
await this._layoutElement.updateComplete;
|
||||
}
|
||||
await this._layoutElement!.initialRenderComplete;
|
||||
this._resolveInitialRender?.();
|
||||
this._resolveInitialRender = undefined;
|
||||
}
|
||||
|
||||
private _createLayoutElement(config: LovelaceViewConfig): void {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -32,8 +31,6 @@ class PanelMaintenance extends LitElement {
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -130,7 +127,6 @@ class PanelMaintenance extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
@@ -32,8 +31,6 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -130,7 +127,6 @@ class PanelSecurity extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
|
||||
import { subscribeThemes } from "../data/ws-themes";
|
||||
import type { Constructor, HomeAssistant } from "../types";
|
||||
import { updateLaunchScreenLogo } from "../util/launch-screen";
|
||||
import { storeState } from "../util/ha-pref-storage";
|
||||
import type { HassBaseEl } from "./hass-base-mixin";
|
||||
|
||||
@@ -145,6 +146,8 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
true
|
||||
);
|
||||
|
||||
updateLaunchScreenLogo(darkMode);
|
||||
|
||||
if (darkMode !== this.hass.themes.darkMode) {
|
||||
this._updateHass({
|
||||
themes: { ...this.hass.themes!, darkMode },
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"ui": {
|
||||
"init": {
|
||||
"loading": "Loading data",
|
||||
"loading": "Loading...",
|
||||
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
|
||||
"project_from": "A project from the",
|
||||
"error": {
|
||||
@@ -3955,6 +3955,9 @@
|
||||
"about": "About templates",
|
||||
"editor": "Template editor",
|
||||
"result": "Result",
|
||||
"result_placeholder": "Your template result will appear here.",
|
||||
"layout_stacked": "Drag to resize, click for stacked view",
|
||||
"layout_side_by_side": "Drag to resize, click for side-by-side view",
|
||||
"reset": "Reset to demo template",
|
||||
"confirm_reset": "Do you want to reset your current template back to the demo template?",
|
||||
"confirm_clear": "Do you want to clear your current template?",
|
||||
@@ -4211,7 +4214,9 @@
|
||||
"power_stat": "Power sensor",
|
||||
"power_helper": "Pick a sensor which measures grid power in either of {unit}.",
|
||||
"power_from": "Power imported from grid",
|
||||
"power_to": "Power exported to grid"
|
||||
"power_to": "Power exported to grid",
|
||||
"helper_sensor_note": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_note%]",
|
||||
"helper_sensor_in_use": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_in_use%]"
|
||||
},
|
||||
"flow_dialog": {
|
||||
"from": {
|
||||
@@ -4278,7 +4283,9 @@
|
||||
"type_inverted_description": "Positive values indicate charging, negative values indicate discharging.",
|
||||
"type_two_sensors": "Two sensors",
|
||||
"power_from": "Discharge power",
|
||||
"power_to": "Charge power"
|
||||
"power_to": "Charge power",
|
||||
"helper_sensor_note": "Home Assistant will create a helper sensor entity for this option.",
|
||||
"helper_sensor_in_use": "Currently using helper {entity}"
|
||||
}
|
||||
},
|
||||
"gas": {
|
||||
|
||||
+24
-18
@@ -1,12 +1,11 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { render } from "lit";
|
||||
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
|
||||
import { withViewTransition } from "../common/util/view-transition";
|
||||
|
||||
let removalInitiated = false;
|
||||
|
||||
/**
|
||||
* Removes the launch screen with a fade-out view transition.
|
||||
* Removes the launch screen with a CSS fade-out transition.
|
||||
*
|
||||
* @param instant - Removes the launch screen without animation. Used when the
|
||||
* external app covers the frontend with its own splash screen until the
|
||||
@@ -26,23 +25,16 @@ export const removeLaunchScreen = (instant = false): boolean => {
|
||||
return true;
|
||||
}
|
||||
|
||||
withViewTransition((viewTransitionAvailable) => {
|
||||
if (viewTransitionAvailable) {
|
||||
launchScreenElement.classList.add("removing");
|
||||
const durationFromCss = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--ha-animation-duration-normal")
|
||||
.trim();
|
||||
setTimeout(
|
||||
() => {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
return;
|
||||
}
|
||||
|
||||
launchScreenElement.classList.add("removing");
|
||||
const durationFromCss = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--ha-animation-duration-normal")
|
||||
.trim();
|
||||
setTimeout(
|
||||
() => {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
},
|
||||
parseAnimationDuration(durationFromCss || "250ms")
|
||||
);
|
||||
});
|
||||
},
|
||||
parseAnimationDuration(durationFromCss || "250ms")
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -57,6 +49,20 @@ export const renderLaunchScreenContent = (
|
||||
updateLaunchScreenAttribution(attribution);
|
||||
};
|
||||
|
||||
/**
|
||||
* Switches the launch screen OHF logo to the variant matching the applied
|
||||
* theme. The `<picture>` element initially picks a variant based on the system
|
||||
* color scheme, which can differ from the theme the frontend ends up applying.
|
||||
*/
|
||||
export const updateLaunchScreenLogo = (darkMode: boolean) => {
|
||||
const logoSourceElement = document.querySelector<HTMLSourceElement>(
|
||||
"#ha-launch-screen .ohf-logo source"
|
||||
);
|
||||
if (logoSourceElement) {
|
||||
logoSourceElement.media = darkMode ? "all" : "not all";
|
||||
}
|
||||
};
|
||||
|
||||
export const updateLaunchScreenAttribution = (attribution: string) => {
|
||||
const attributionElement = document.getElementById(
|
||||
"ha-launch-screen-attribution"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalAttachInternals = HTMLElement.prototype.attachInternals;
|
||||
const originalElementInternals = window.ElementInternals;
|
||||
|
||||
// The probe memoizes its result, so re-import the module for each scenario.
|
||||
const loadProbe = async () => {
|
||||
vi.resetModules();
|
||||
const mod =
|
||||
await import("../../../src/common/feature-detect/support-native-element-internals");
|
||||
return mod.supportsNativeElementInternals;
|
||||
};
|
||||
|
||||
describe("supportsNativeElementInternals", () => {
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.attachInternals = originalAttachInternals;
|
||||
window.ElementInternals = originalElementInternals;
|
||||
});
|
||||
|
||||
it("returns true with native ElementInternals", async () => {
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when attachInternals is wrapped by a delegating function", async () => {
|
||||
// Simulates @webcomponents/scoped-custom-element-registry, which the app
|
||||
// bundle loads. Wrapping used to make detection fail (#53337).
|
||||
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
|
||||
return originalAttachInternals.call(this);
|
||||
};
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when element-internals-polyfill replaces the global", async () => {
|
||||
// The polyfill swaps in its own class, so the mere presence of
|
||||
// window.ElementInternals says nothing about native support (#51338).
|
||||
class PolyfilledElementInternals {
|
||||
public setFormValue = (): void => undefined;
|
||||
|
||||
public setValidity = (): void => undefined;
|
||||
|
||||
public checkValidity = (): boolean => true;
|
||||
|
||||
public reportValidity = (): boolean => true;
|
||||
}
|
||||
window.ElementInternals =
|
||||
PolyfilledElementInternals as unknown as typeof window.ElementInternals;
|
||||
HTMLElement.prototype.attachInternals = () =>
|
||||
new PolyfilledElementInternals() as unknown as ElementInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without ElementInternals", async () => {
|
||||
window.ElementInternals =
|
||||
undefined as unknown as typeof window.ElementInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without attachInternals", async () => {
|
||||
HTMLElement.prototype.attachInternals =
|
||||
undefined as unknown as typeof originalAttachInternals;
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
|
||||
it("probes on first use rather than on import", async () => {
|
||||
// Imported while support is present, so an eager probe would cache true.
|
||||
const supportsNativeElementInternals = await loadProbe();
|
||||
window.ElementInternals =
|
||||
undefined as unknown as typeof window.ElementInternals;
|
||||
expect(supportsNativeElementInternals()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeNavigationPath } from "../../../src/common/url/sanitize-navigation-path";
|
||||
|
||||
describe("sanitizeNavigationPath", () => {
|
||||
it("keeps paths on the current origin", () => {
|
||||
expect(sanitizeNavigationPath("/")).toEqual("/");
|
||||
expect(sanitizeNavigationPath("/config/areas")).toEqual("/config/areas");
|
||||
expect(sanitizeNavigationPath("/energy?historyBack=1")).toEqual(
|
||||
"/energy?historyBack=1"
|
||||
);
|
||||
expect(sanitizeNavigationPath("config/areas")).toEqual("config/areas");
|
||||
expect(sanitizeNavigationPath(`${location.origin}/lovelace/0`)).toEqual(
|
||||
`${location.origin}/lovelace/0`
|
||||
);
|
||||
});
|
||||
|
||||
/* eslint-disable no-script-url */
|
||||
it("rejects URIs with their own scheme", () => {
|
||||
expect(sanitizeNavigationPath("javascript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("JavaScript:alert(1)")).toBeUndefined();
|
||||
// the URL parser strips tabs and newlines, just like the browser does for href
|
||||
expect(sanitizeNavigationPath("java\tscript:alert(1)")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath(" javascript:alert(1)")).toBeUndefined();
|
||||
expect(
|
||||
sanitizeNavigationPath("data:text/html,<script>alert(1)</script>")
|
||||
).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("vbscript:msgbox(1)")).toBeUndefined();
|
||||
});
|
||||
/* eslint-enable no-script-url */
|
||||
|
||||
it("rejects other origins", () => {
|
||||
expect(sanitizeNavigationPath("https://example.com/")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("//example.com/")).toBeUndefined();
|
||||
expect(sanitizeNavigationPath("\\\\example.com/")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects missing values", () => {
|
||||
expect(sanitizeNavigationPath(undefined)).toBeUndefined();
|
||||
expect(sanitizeNavigationPath(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { html, nothing, render } from "lit";
|
||||
import "../../src/components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableRowData,
|
||||
HaDataTable,
|
||||
} from "../../src/components/data-table/ha-data-table";
|
||||
|
||||
const columns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
filled_template: { title: "Filled", template: () => html`filled` },
|
||||
};
|
||||
|
||||
// The narrow row puts every non-main column on a secondary line, joined by dots.
|
||||
const renderNarrowSecondary = (row: DataTableRowData) => {
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render((el as any)._renderRow(columns, true, row, 0), container);
|
||||
return container.querySelector(".secondary")!.textContent!.trim();
|
||||
};
|
||||
|
||||
describe("ha-data-table narrow secondary line", () => {
|
||||
it("does not render separators for empty columns", () => {
|
||||
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
|
||||
});
|
||||
|
||||
it("separates only the columns that have a value", () => {
|
||||
expect(
|
||||
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
|
||||
).toBe("Kitchen · filled");
|
||||
});
|
||||
|
||||
it("renders a blank secondary line when all secondary columns are empty", () => {
|
||||
const emptyColumns: DataTableColumnContainer = {
|
||||
name: { title: "Name", main: true },
|
||||
area: { title: "Area" },
|
||||
category: { title: "Category" },
|
||||
empty_template: { title: "Empty", template: () => nothing },
|
||||
};
|
||||
const el = document.createElement("ha-data-table") as HaDataTable;
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
|
||||
container
|
||||
);
|
||||
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { LitElement } from "lit";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WaInput } from "../../../src/components/input/wa-input-mixin";
|
||||
import { WaInputMixin } from "../../../src/components/input/wa-input-mixin";
|
||||
|
||||
const supportsNative = vi.hoisted(() => ({ value: true }));
|
||||
|
||||
vi.mock(
|
||||
"../../../src/common/feature-detect/support-native-element-internals",
|
||||
() => ({
|
||||
supportsNativeElementInternals: () => supportsNative.value,
|
||||
})
|
||||
);
|
||||
|
||||
// Subclassing gives legitimate access to the mixin's protected members, which
|
||||
// ha-input and ha-textarea reach through their @input/@blur/@wa-invalid bindings.
|
||||
class TestInput extends WaInputMixin(LitElement) {
|
||||
public controlValid = true;
|
||||
|
||||
protected get _formControl(): WaInput {
|
||||
return {
|
||||
value: "",
|
||||
select: () => undefined,
|
||||
setSelectionRange: () => undefined,
|
||||
setRangeText: () => undefined,
|
||||
checkValidity: () => this.controlValid,
|
||||
validationMessage: "Please fill out this field.",
|
||||
};
|
||||
}
|
||||
|
||||
public get isInvalid(): boolean {
|
||||
return this._invalid;
|
||||
}
|
||||
|
||||
public set isInvalid(value: boolean) {
|
||||
this._invalid = value;
|
||||
}
|
||||
|
||||
public handleInput(): void {
|
||||
this._handleInput();
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._handleBlur();
|
||||
}
|
||||
|
||||
public handleInvalid(): void {
|
||||
this._handleInvalid();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-wa-input-mixin", TestInput);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-wa-input-mixin": TestInput;
|
||||
}
|
||||
}
|
||||
|
||||
const createInput = (props: Partial<TestInput> = {}): TestInput => {
|
||||
const el = document.createElement("test-wa-input-mixin");
|
||||
Object.assign(el, props);
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("WaInputMixin validity", () => {
|
||||
beforeEach(() => {
|
||||
supportsNative.value = true;
|
||||
});
|
||||
|
||||
describe("with native element internals", () => {
|
||||
it("marks invalid on blur when auto-validate is set", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps valid on blur when the control is valid", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: true });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores blur without auto-validate", () => {
|
||||
const el = createInput({ autoValidate: false, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid on input once the control is valid", () => {
|
||||
const el = createInput({ controlValid: true });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps invalid on input while the control is invalid", () => {
|
||||
const el = createInput({ controlValid: false });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it("marks invalid on the invalid event", () => {
|
||||
const el = createInput();
|
||||
el.handleInvalid();
|
||||
expect(el.isInvalid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Polyfilled internals report validity the app cannot trust, so every path
|
||||
// must agree with checkValidity() and leave the field alone (#51338).
|
||||
describe("without native element internals", () => {
|
||||
beforeEach(() => {
|
||||
supportsNative.value = false;
|
||||
});
|
||||
|
||||
it("does not mark invalid on blur", () => {
|
||||
const el = createInput({ autoValidate: true, controlValid: false });
|
||||
el.handleBlur();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("clears invalid on input", () => {
|
||||
const el = createInput({ controlValid: false });
|
||||
el.isInvalid = true;
|
||||
el.handleInput();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the invalid event", () => {
|
||||
const el = createInput();
|
||||
el.handleInvalid();
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it("reports valid so submission is never blocked", () => {
|
||||
const el = createInput({ required: true, controlValid: false });
|
||||
expect(el.checkValidity()).toBe(true);
|
||||
expect(el.reportValidity()).toBe(true);
|
||||
expect(el.isInvalid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-96
@@ -4,7 +4,7 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:app
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
appSidebar,
|
||||
appSidebarConfig,
|
||||
@@ -161,106 +161,11 @@ test.describe("Quick search", () => {
|
||||
|
||||
defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
|
||||
const assertInitialReadiness = async (
|
||||
page: Page,
|
||||
options: {
|
||||
path: string;
|
||||
loadingSelector: string;
|
||||
resolver:
|
||||
"rejectMediaBrowse" | "resolveCalendarRegistry" | "resolveMediaBrowse";
|
||||
readySelector: string;
|
||||
}
|
||||
) => {
|
||||
await goToPanel(page, options.path);
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator(options.loadingSelector)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
await page.evaluate((resolver) => window[resolver]?.(), options.resolver);
|
||||
|
||||
await expect(page.locator(options.readySelector)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
};
|
||||
|
||||
test.describe("Initial readiness", () => {
|
||||
test("keeps the launch screen until calendar content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertInitialReadiness(page, {
|
||||
path: "/?scenario=delayed-calendar#/calendar",
|
||||
loadingSelector: "ha-panel-calendar ha-spinner",
|
||||
resolver: "resolveCalendarRegistry",
|
||||
readySelector: "ha-full-calendar",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the launch screen until media content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertInitialReadiness(page, {
|
||||
path: "/?scenario=delayed-media-browse#/media-browser/browser",
|
||||
loadingSelector: "ha-media-player-browse > ha-spinner",
|
||||
resolver: "resolveMediaBrowse",
|
||||
readySelector: "ha-media-player-browse .no-items",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the launch screen until a media error renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await assertInitialReadiness(page, {
|
||||
path: "/?scenario=delayed-media-browse-error#/media-browser/browser",
|
||||
loadingSelector: "ha-media-player-browse > ha-spinner",
|
||||
resolver: "rejectMediaBrowse",
|
||||
readySelector: "ha-media-player-browse ha-alert",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Lovelace dashboard", () => {
|
||||
test("keeps the launch screen until generated content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-view")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveGeneratedDashboard?.());
|
||||
|
||||
await expect(page.locator("hui-view")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("keeps the launch screen until initial content renders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
|
||||
|
||||
const launchScreen = page.locator("#ha-launch-screen");
|
||||
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(page.locator("hui-card")).not.toBeAttached();
|
||||
|
||||
await page.evaluate(() => window.resolveLovelaceConfig?.());
|
||||
|
||||
await expect(page.locator("hui-card").first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("renders cards", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// At least one card should appear
|
||||
|
||||
@@ -92,11 +92,6 @@ declare global {
|
||||
interface Window {
|
||||
__assistRun?: unknown;
|
||||
__mockHass: MockHomeAssistant;
|
||||
rejectMediaBrowse?: () => void;
|
||||
resolveCalendarRegistry?: () => void;
|
||||
resolveGeneratedDashboard?: () => void;
|
||||
resolveLovelaceConfig?: () => void;
|
||||
resolveMediaBrowse?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
|
||||
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
} from "../../../../../src/data/entity/entity_registry";
|
||||
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
|
||||
import type { MediaPlayerItem } from "../../../../../src/data/media-player";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
@@ -129,98 +124,6 @@ const quickSearchAssistScenario: Scenario = async (hass) => {
|
||||
});
|
||||
};
|
||||
|
||||
const addLaunchScreen = () => {
|
||||
const launchScreen = document.createElement("div");
|
||||
launchScreen.id = "ha-launch-screen";
|
||||
document.body.prepend(launchScreen);
|
||||
};
|
||||
|
||||
const delayedLovelaceScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const config: LovelaceRawConfig = {
|
||||
views: [
|
||||
{
|
||||
title: "Home",
|
||||
cards: [{ type: "markdown", content: "Dashboard ready" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
let resolveConfig: ((config: LovelaceRawConfig) => void) | undefined;
|
||||
const configPromise = new Promise<LovelaceRawConfig>((resolve) => {
|
||||
resolveConfig = resolve;
|
||||
});
|
||||
|
||||
window.resolveLovelaceConfig = () => resolveConfig?.(config);
|
||||
hass.mockWS("lovelace/config", () => configPromise);
|
||||
};
|
||||
|
||||
const delayedGeneratedDashboardScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const loadFragmentTranslation = hass.loadFragmentTranslation;
|
||||
let resolveTranslation: (() => void) | undefined;
|
||||
const translationReady = new Promise<void>((resolve) => {
|
||||
resolveTranslation = resolve;
|
||||
});
|
||||
|
||||
hass.loadFragmentTranslation = async (fragment) => {
|
||||
if (fragment === "lovelace") {
|
||||
await translationReady;
|
||||
}
|
||||
return loadFragmentTranslation(fragment);
|
||||
};
|
||||
window.resolveGeneratedDashboard = resolveTranslation;
|
||||
};
|
||||
|
||||
const delayedCalendarScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
let resolveRegistry: ((entries: EntityRegistryEntry[]) => void) | undefined;
|
||||
const registryPromise = new Promise<EntityRegistryEntry[]>((resolve) => {
|
||||
resolveRegistry = resolve;
|
||||
});
|
||||
|
||||
window.resolveCalendarRegistry = () => resolveRegistry?.([]);
|
||||
hass.mockWS("config/entity_registry/list", () => registryPromise);
|
||||
};
|
||||
|
||||
const delayedMediaBrowseScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
const root: MediaPlayerItem = {
|
||||
title: "Media",
|
||||
media_content_id: "media-source://media_source",
|
||||
media_content_type: "app",
|
||||
media_class: "directory",
|
||||
can_play: false,
|
||||
can_expand: true,
|
||||
can_search: false,
|
||||
children: [],
|
||||
};
|
||||
let resolveBrowse: ((item: MediaPlayerItem) => void) | undefined;
|
||||
const browsePromise = new Promise<MediaPlayerItem>((resolve) => {
|
||||
resolveBrowse = resolve;
|
||||
});
|
||||
|
||||
window.resolveMediaBrowse = () => resolveBrowse?.(root);
|
||||
hass.mockWS("media_source/browse_media", () => browsePromise);
|
||||
};
|
||||
|
||||
const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
|
||||
addLaunchScreen();
|
||||
|
||||
let rejectBrowse:
|
||||
((reason: { code: string; message: string }) => void) | undefined;
|
||||
const browsePromise = new Promise<MediaPlayerItem>((_resolve, reject) => {
|
||||
rejectBrowse = reject;
|
||||
});
|
||||
|
||||
window.rejectMediaBrowse = () =>
|
||||
rejectBrowse?.({ code: "unknown_error", message: "Browse failed" });
|
||||
hass.mockWS("media_source/browse_media", () => browsePromise);
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
@@ -228,11 +131,6 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"non-admin": nonAdminScenario,
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"delayed-calendar": delayedCalendarScenario,
|
||||
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
|
||||
"delayed-media-browse": delayedMediaBrowseScenario,
|
||||
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"quick-search-assist": quickSearchAssistScenario,
|
||||
"delayed-lovelace": delayedLovelaceScenario,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-subpage");
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (backPath: string) => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const element = document.createElement("hass-subpage");
|
||||
element.setAttribute("back-path", backPath);
|
||||
host.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
describe("hass-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config/system");
|
||||
expect(backButton!.getAttribute("href")).toEqual("/config/system");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
it.each(["javascript:alert(1)", "https://example.com/"])(
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.hasAttribute("href")).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
vi.mock("../../src/components/ha-tab", () => ({}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
customElements.define("ha-tab", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-tabs-subpage");
|
||||
|
||||
const hass = {
|
||||
config: { components: [] },
|
||||
language: "en",
|
||||
localize: (key: string) => key,
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (backPath: string) => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const element = document.createElement(
|
||||
"hass-tabs-subpage"
|
||||
) as HassTabsSubpage;
|
||||
Object.assign(element, {
|
||||
hass,
|
||||
route: { prefix: "", path: "" },
|
||||
tabs: [],
|
||||
backPath,
|
||||
});
|
||||
host.append(element);
|
||||
await element.updateComplete;
|
||||
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev") as
|
||||
(LitElement & { href?: string }) | null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
describe("hass-tabs-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config");
|
||||
expect(backButton!.href).toEqual("/config");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
it.each(["javascript:alert(1)", "https://example.com/"])(
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.href).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("getPowerHelperEntityId", () => {
|
||||
it("returns the helper for an inverted config", () => {
|
||||
const powerConfig = { stat_rate_inverted: "sensor.battery_power" };
|
||||
assert.strictEqual(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.battery_power_inverted",
|
||||
power_config: powerConfig,
|
||||
},
|
||||
{ ...powerConfig }
|
||||
),
|
||||
"sensor.battery_power_inverted"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the helper for a two sensor config", () => {
|
||||
const powerConfig = {
|
||||
stat_rate_from: "sensor.discharge",
|
||||
stat_rate_to: "sensor.charge",
|
||||
};
|
||||
assert.strictEqual(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.energy_battery_discharge_charge_net_power",
|
||||
power_config: powerConfig,
|
||||
},
|
||||
{ ...powerConfig }
|
||||
),
|
||||
"sensor.energy_battery_discharge_charge_net_power"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for a standard config, as no helper is created", () => {
|
||||
const powerConfig = { stat_rate: "sensor.battery_power" };
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(
|
||||
{ stat_rate: "sensor.battery_power", power_config: powerConfig },
|
||||
{ ...powerConfig }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for a legacy config without power_config", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId({ stat_rate: "sensor.battery_power" }, {})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing when the config was edited", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(
|
||||
{
|
||||
stat_rate: "sensor.battery_power_inverted",
|
||||
power_config: { stat_rate_inverted: "sensor.battery_power" },
|
||||
},
|
||||
{ stat_rate_inverted: "sensor.other_battery_power" }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an unsaved source", () => {
|
||||
assert.isUndefined(
|
||||
getPowerHelperEntityId(undefined, {
|
||||
stat_rate_inverted: "sensor.battery_power",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isPowerConfigValid } from "../../../../src/panels/config/energy/dialogs/power-config";
|
||||
|
||||
describe("isPowerConfigValid", () => {
|
||||
it("accepts any config when no power sensor is configured", () => {
|
||||
expect(isPowerConfigValid("none", {})).toBe(true);
|
||||
expect(isPowerConfigValid("none", { stat_rate: "sensor.power" })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a rate statistic for the standard type", () => {
|
||||
expect(isPowerConfigValid("standard", {})).toBe(false);
|
||||
expect(isPowerConfigValid("standard", { stat_rate: "" })).toBe(false);
|
||||
expect(isPowerConfigValid("standard", { stat_rate: "sensor.power" })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isPowerConfigValid("standard", { stat_rate_inverted: "sensor.power" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an inverted rate statistic for the inverted type", () => {
|
||||
expect(isPowerConfigValid("inverted", {})).toBe(false);
|
||||
expect(isPowerConfigValid("inverted", { stat_rate: "sensor.power" })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isPowerConfigValid("inverted", { stat_rate_inverted: "sensor.power" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires both statistics for the two sensors type", () => {
|
||||
expect(isPowerConfigValid("two_sensors", {})).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", { stat_rate_from: "sensor.power_from" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", { stat_rate_to: "sensor.power_to" })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPowerConfigValid("two_sensors", {
|
||||
stat_rate_from: "sensor.power_from",
|
||||
stat_rate_to: "sensor.power_to",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown power types", () => {
|
||||
expect(
|
||||
isPowerConfigValid("unexpected" as never, { stat_rate: "sensor.power" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,4 @@ global.navigator = (global.navigator ?? {}) as any;
|
||||
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
global.__HASS_URL__ = "";
|
||||
|
||||
@@ -27,43 +27,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/css-color@npm:^5.1.11":
|
||||
version: 5.1.11
|
||||
resolution: "@asamuzakjp/css-color@npm:5.1.11"
|
||||
"@asamuzakjp/css-color@npm:^6.0.5":
|
||||
version: 6.0.5
|
||||
resolution: "@asamuzakjp/css-color@npm:6.0.5"
|
||||
dependencies:
|
||||
"@asamuzakjp/generational-cache": "npm:^1.0.1"
|
||||
"@csstools/css-calc": "npm:^3.2.0"
|
||||
"@csstools/css-color-parser": "npm:^4.1.0"
|
||||
"@csstools/css-calc": "npm:^3.2.1"
|
||||
"@csstools/css-color-parser": "npm:^4.1.9"
|
||||
"@csstools/css-parser-algorithms": "npm:^4.0.0"
|
||||
"@csstools/css-tokenizer": "npm:^4.0.0"
|
||||
checksum: 10/2e337cc94b5a3f9741a27f92b4e4b7dc467a76b1dcf66c40e71808fed71695f10c8cf07c8b13313cbb637154314ca1d8626bb9a045fe94b404b242a390cf3bd3
|
||||
lru-cache: "npm:^11.5.2"
|
||||
checksum: 10/bd88a9a1d00711f5f63c4288a82489c33e28de43dee37f418cd30a42517d7b7e2040dc79fc223ececf290e7b3f12bbd2dd8ce7450f73b03bf0613df449b9563f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/dom-selector@npm:^7.1.1":
|
||||
version: 7.1.1
|
||||
resolution: "@asamuzakjp/dom-selector@npm:7.1.1"
|
||||
"@asamuzakjp/dom-selector@npm:^8.2.5":
|
||||
version: 8.3.0
|
||||
resolution: "@asamuzakjp/dom-selector@npm:8.3.0"
|
||||
dependencies:
|
||||
"@asamuzakjp/generational-cache": "npm:^1.0.1"
|
||||
"@asamuzakjp/nwsapi": "npm:^2.3.9"
|
||||
bidi-js: "npm:^1.0.3"
|
||||
css-tree: "npm:^3.2.1"
|
||||
is-potential-custom-element-name: "npm:^1.0.1"
|
||||
checksum: 10/49a065a64db5f53a3008c231d09606e4b67f509fa20148a67419451c2dc91a421202ed17bfc4bc679ad2f0432d7260720d602c1d5c9c5e165931fff5199c3f12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/generational-cache@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "@asamuzakjp/generational-cache@npm:1.0.1"
|
||||
checksum: 10/e1cf3f1916a334c6153f624982f0eb3d50fa3048435ea5c5b0f441f8f1ab74a0fe992dac214b612d22c0acafad3cd1a1f6b45d99c7b6e3b63cfdf7f6ca5fc144
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/nwsapi@npm:^2.3.9":
|
||||
version: 2.3.9
|
||||
resolution: "@asamuzakjp/nwsapi@npm:2.3.9"
|
||||
checksum: 10/95a6d1c102e1117fe818da087fcc5b914d23e0699855991bae50b891435dd1945ad7d384198f8bcf616207fd85b7ec32e3db6b96e9309d84c6903b8dc4151e34
|
||||
lru-cache: "npm:^11.5.2"
|
||||
checksum: 10/458632671954613e1bd653c9c9d2e8ded44701f19ca8a9641ec3fa0df9eb60089769e0c98dd7991afd53b86e40f8a720024653f451af71ee644d6cf3241c9ec0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2482,26 +2467,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/css-calc@npm:^3.2.0, @csstools/css-calc@npm:^3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "@csstools/css-calc@npm:3.2.1"
|
||||
"@csstools/css-calc@npm:^3.2.1, @csstools/css-calc@npm:^3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "@csstools/css-calc@npm:3.3.0"
|
||||
peerDependencies:
|
||||
"@csstools/css-parser-algorithms": ^4.0.0
|
||||
"@csstools/css-tokenizer": ^4.0.0
|
||||
checksum: 10/39042a9382cbd7c4fa241c1a6c10d64c23fa7653970fc11df532e9bc42a366a8b50c3ac3036bd5f2a77b94144e7f804f19d2b52800ad2f48bd9a3840377165d8
|
||||
checksum: 10/46dde1c26c8a62d7b849a616a521cb97abc2d6c8c480f7be6f14c70fe60689a2a58c1f5f905aae237250ad27172968e10c70f6f83864c7dc48d8f8921f55f6a9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/css-color-parser@npm:^4.1.0":
|
||||
version: 4.1.9
|
||||
resolution: "@csstools/css-color-parser@npm:4.1.9"
|
||||
"@csstools/css-color-parser@npm:^4.1.9":
|
||||
version: 4.1.10
|
||||
resolution: "@csstools/css-color-parser@npm:4.1.10"
|
||||
dependencies:
|
||||
"@csstools/color-helpers": "npm:^6.1.0"
|
||||
"@csstools/css-calc": "npm:^3.2.1"
|
||||
"@csstools/css-calc": "npm:^3.3.0"
|
||||
peerDependencies:
|
||||
"@csstools/css-parser-algorithms": ^4.0.0
|
||||
"@csstools/css-tokenizer": ^4.0.0
|
||||
checksum: 10/6369b601bcd3a8ce58dbcdc389a732b754c8f3fc35421b37169f6d4b07c7261f7b8c23e7d04e457da5e5e0bb082e680acb137d8c86fa1f7d6ff14ea873bf02a2
|
||||
checksum: 10/92f5f590617a2cc9ff358b2d79716cbe15dd4cc7537cdc45ffc55878282677a9ef6c8bfb215a7dc0731f302c32bf1b0d1b7fc3c550147fd8a6d85f7c3cfb0cdd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2514,15 +2499,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.3":
|
||||
version: 1.1.6
|
||||
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.6"
|
||||
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.6":
|
||||
version: 1.1.7
|
||||
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.7"
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
checksum: 10/8747268b42f1afbe450d38b7f388a4983c810883f8c0716fa24f5b1eef0f9cbaa3a0f4ea72d1e5cbd015dc4fd5af9cc96ade42792912ed2dcc1d4054de102163
|
||||
checksum: 10/ba372ab41c351509d47e77f58eb56112fe6fd42e104b70c9c9c797faf4fbb3e837a5df5c9d7e3464b04607ee42e91701e34f935aeec8abb94f24be67cfa1b707
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2726,7 +2711,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0":
|
||||
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.1, @exodus/bytes@npm:^1.6.0":
|
||||
version: 1.15.1
|
||||
resolution: "@exodus/bytes@npm:1.15.1"
|
||||
peerDependencies:
|
||||
@@ -9675,10 +9660,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globals@npm:17.7.0":
|
||||
version: 17.7.0
|
||||
resolution: "globals@npm:17.7.0"
|
||||
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
|
||||
"globals@npm:17.8.0":
|
||||
version: 17.8.0
|
||||
resolution: "globals@npm:17.8.0"
|
||||
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10011,7 +9996,7 @@ __metadata:
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.7.0"
|
||||
globals: "npm:17.8.0"
|
||||
gulp: "npm:5.0.1"
|
||||
gulp-brotli: "npm:3.0.0"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
@@ -10024,7 +10009,7 @@ __metadata:
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.12"
|
||||
js-yaml: "npm:5.2.2"
|
||||
jsdom: "npm:29.1.1"
|
||||
jsdom: "npm:30.0.0"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
|
||||
@@ -10979,37 +10964,37 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsdom@npm:29.1.1":
|
||||
version: 29.1.1
|
||||
resolution: "jsdom@npm:29.1.1"
|
||||
"jsdom@npm:30.0.0":
|
||||
version: 30.0.0
|
||||
resolution: "jsdom@npm:30.0.0"
|
||||
dependencies:
|
||||
"@asamuzakjp/css-color": "npm:^5.1.11"
|
||||
"@asamuzakjp/dom-selector": "npm:^7.1.1"
|
||||
"@asamuzakjp/css-color": "npm:^6.0.5"
|
||||
"@asamuzakjp/dom-selector": "npm:^8.2.5"
|
||||
"@bramus/specificity": "npm:^2.4.2"
|
||||
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.3"
|
||||
"@exodus/bytes": "npm:^1.15.0"
|
||||
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.6"
|
||||
"@exodus/bytes": "npm:^1.15.1"
|
||||
css-tree: "npm:^3.2.1"
|
||||
data-urls: "npm:^7.0.0"
|
||||
decimal.js: "npm:^10.6.0"
|
||||
html-encoding-sniffer: "npm:^6.0.0"
|
||||
is-potential-custom-element-name: "npm:^1.0.1"
|
||||
lru-cache: "npm:^11.3.5"
|
||||
lru-cache: "npm:^11.5.2"
|
||||
parse5: "npm:^8.0.1"
|
||||
saxes: "npm:^6.0.0"
|
||||
symbol-tree: "npm:^3.2.4"
|
||||
tough-cookie: "npm:^6.0.1"
|
||||
undici: "npm:^7.25.0"
|
||||
tough-cookie: "npm:^6.0.2"
|
||||
undici: "npm:^8.7.0"
|
||||
w3c-xmlserializer: "npm:^5.0.0"
|
||||
webidl-conversions: "npm:^8.0.1"
|
||||
whatwg-mimetype: "npm:^5.0.0"
|
||||
whatwg-url: "npm:^16.0.1"
|
||||
whatwg-url: "npm:^17.1.0"
|
||||
xml-name-validator: "npm:^5.0.0"
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
canvas: ^3.2.3
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
checksum: 10/344aed7f91839b6c7d1b40778c5542d6ded7d42d88e1b787e10bf12d4ccd65464a5f23f774eb84350885c75a48efc99f6972adbb94dffe324a1b065d3650843c
|
||||
checksum: 10/dc73c071e67224465018733525047d05772667bd8e00a3703a6f6515238c027b6ca768bf86874a53d512fd0bb962d72cc6a6c31c1bc38e52318f9d412ad0bb90
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11595,7 +11580,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.3.5":
|
||||
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.5.2":
|
||||
version: 11.5.2
|
||||
resolution: "lru-cache@npm:11.5.2"
|
||||
checksum: 10/122789c66605ddf29df58ff279e9e2c9499499d0b80b895ec524496f14bcde045d1582e3e38008f3457226d98067556719573b352119d6be8bdb1f8849f85681
|
||||
@@ -14745,7 +14730,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tough-cookie@npm:^6.0.1":
|
||||
"tough-cookie@npm:^6.0.2":
|
||||
version: 6.0.2
|
||||
resolution: "tough-cookie@npm:6.0.2"
|
||||
dependencies:
|
||||
@@ -15071,17 +15056,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^7.25.0":
|
||||
version: 7.28.0
|
||||
resolution: "undici@npm:7.28.0"
|
||||
checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^8.4.1":
|
||||
version: 8.7.0
|
||||
resolution: "undici@npm:8.7.0"
|
||||
checksum: 10/e9644903bda97f825228b4c7bee95b3586609f94df685b598c32bdaf3ac795928cbd25b0ed2690c3ea0b5abd324f5a9641b63b6c81c9b19f3ef4d5b3e402a48c
|
||||
"undici@npm:^8.4.1, undici@npm:^8.7.0":
|
||||
version: 8.9.0
|
||||
resolution: "undici@npm:8.9.0"
|
||||
checksum: 10/dfad3e233087eafdf1d361acd17c4d45a9590d5db9400458f1c03f47d8f1ef68647e2109ebb982c862e50c227093abd2d5f44b154904fc0b3d22576b6e95cfe7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15710,7 +15688,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1":
|
||||
"whatwg-url@npm:^16.0.0":
|
||||
version: 16.0.1
|
||||
resolution: "whatwg-url@npm:16.0.1"
|
||||
dependencies:
|
||||
@@ -15721,6 +15699,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^17.1.0":
|
||||
version: 17.1.0
|
||||
resolution: "whatwg-url@npm:17.1.0"
|
||||
dependencies:
|
||||
"@exodus/bytes": "npm:^1.15.1"
|
||||
tr46: "npm:^6.0.0"
|
||||
webidl-conversions: "npm:^8.0.1"
|
||||
checksum: 10/52052ba12a63e7665ee49d84d251a3d4776be4777fb259155ea3ca9bc49cdbbd18cbb5fce50f187e8450ce81d7abbbc4c39cfb15207c6d11d5c2539f9b87d426
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "whatwg-url@npm:5.0.0"
|
||||
|
||||
Reference in New Issue
Block a user