Compare commits

..
Author SHA1 Message Date
Petar Petrov b9b6f4942f Add keyboard and screen reader navigation to charts via Chart2Music 2026-08-06 16:05:28 +03:00
Aidan TimsonandGitHub ea5a1bca99 Add onboarding end-to-end test (#53514)
* Add onboarding end-to-end test

* Reuse onboarding test data

* Fix onboarding E2E server routing

* Test onboarding through authenticated dashboard
2026-08-06 14:04:44 +03:00
7ae5dafc95 Map card scale ruler (#53494)
* Scale ruler option for map card

* Only show ruler for the unit system configured

* Move attribution when ruler is in the same corner

* Match ruler style with the map and HA

* Update map card gallery page with the scale ruler options

* Use "UNIT_KM" to set the ruler's unit system

* Remove position option in favor of a boolean for only the bottom-left position

* Add dark mode theme to scale ruler

* Fix scale ruler style not respecting theme_mode

* Make the map card scale ruler react to config changes

Redraw the control from update() so toggling the option or changing the
unit system takes effect without a reload, and hold the themed colors in
custom properties so the forced light/dark rules no longer duplicate the
token values.

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-06 10:15:39 +00:00
Abílio CostaandGitHub 52cc65cbfc Delay camera cleanup when hidden (#53518) 2026-08-06 09:35:07 +01:00
Paul BotteinandGitHub f36330274d Stack the sidebar when only one column fits (#53515) 2026-08-06 11:26:19 +03:00
renovate[bot]andGitHub a4912d0706 Update dependency globals to v17.9.0 (#53519) 2026-08-06 09:02:18 +01:00
Krisjanis LejejsandGitHub 3900a804f0 Remove cloud demo controls (#53524) 2026-08-06 02:42:29 -04:00
cd5dfdb86f Mock entity ID format WS commands in the demo (#53520)
Co-authored-by: Claude <[email protected]>
2026-08-05 19:00:40 +00:00
Bram KragtenandGitHub a602865117 Don't disable interaction when row is disabled (#53517)
dont disable interaction when row is disabled
2026-08-05 17:31:21 +02:00
Petar PetrovandGitHub f42a5012a9 Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-05 12:52:52 +02:00
Petar PetrovandGitHub 90f5c7a349 Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-05 12:31:15 +02:00
d03ba15c09 Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-05 11:54:11 +03:00
Petar PetrovandGitHub 7143acc860 Remove orphaned for_you translation key (#53509) 2026-08-05 08:30:11 +01:00
Aidan TimsonandGitHub c9c46d4507 Use typed gallery demo card configs (#53492) 2026-08-05 08:26:51 +01:00
5916775745 Wait for Integrations panel readiness (#53481)
* Wait for Integrations panel readiness

* Consolidate panel readiness tests

* Address panel readiness review feedback

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-05 08:44:16 +03:00
pcan08andGitHub 52ee78d8a2 Add support for climate in target humidity feature (#53502)
* Add target humidity feature to climate entity

* Use target_humidity_step if any instead of fixed step

* Add target humidity for climate in suggestd tile card
2026-08-05 08:30:45 +03:00
146a089044 Type configuration event handlers (#53460)
* Type configuration event handlers

* Fix event handler element typings

Co-authored-by: timmo001 <[email protected]>

* Fix Prettier formatting in zha-options-page.ts and zwave_js-custom-param.ts

Co-authored-by: timmo001 <[email protected]>

* Correction

Co-authored-by: Petar Petrov <[email protected]>

* Correction

Co-authored-by: Petar Petrov <[email protected]>

* Fix TS cast and formatting in dialog-energy-grid-settings

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-05 08:23:14 +03:00
Paul BotteinandGitHub 8c566b43b6 Compress faster by running brotli in parallel (#53495) 2026-08-05 08:19:30 +03:00
1089c5d1c5 Fix missing Internet heading in Home Assistant URL settings (#53498)
fix(config): always show the Internet URL heading

The "Internet" heading above the external URL field is rendered
inside a `hasCloud` conditional, so only users logged in to Home
Assistant Cloud ever see it. Everyone else gets an unlabeled URL
input, while the "Local network" heading below it always renders.
That leaves the local heading as the only label on the card, and it
sits below the external field, so the external field reads as though
it belongs to the local section.

Move the heading out of the conditional so it renders for every
user, leaving `hasCloud` to gate only the "Use Home Assistant Cloud"
toggle. The external section now has the same structure as the local
one: a heading followed by its list item.

The label was shown to non-Cloud users before #22379, inline beside
the field rather than as a heading. That PR converted both labels to
headings, kept the Cloud branch and dropped the non-Cloud one.


Claude-Session: https://claude.ai/code/session_01GQCiHCdQ4p3cPxXG6XNAa9

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-05 08:16:35 +03:00
Petar PetrovandGitHub 2894113033 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 08:02:58 +03:00
Petar PetrovandGitHub b9bbef3bfc Add expand_legend option to energy graph cards (#53476) 2026-08-05 08:02:28 +03:00
Paul BotteinandGitHub 706382cb68 Fix back navigation loop on the cloud page (#53497) 2026-08-05 06:50:34 +02:00
karwostsandGitHub cc17921c22 Fix missing label in solar forecast dialog (#53506) 2026-08-05 06:49:42 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b5ef563b71 Update octokit monorepo (#53505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 06:43:34 +02:00
182 changed files with 4864 additions and 3066 deletions
+46
View File
@@ -0,0 +1,46 @@
// Gulp transform that brotli-compresses files, several at a time.
//
// Drop-in replacement for gulp-brotli. zlib already does the work off the main
// thread, but that plugin wraps it in through2, which waits for each file
// before starting the next, so only one compression is ever in flight. The
// compressed bytes are unchanged; only how many run at once differs.
//
// The real ceiling is libuv's threadpool, which zlib runs on. It sizes itself
// from UV_THREADPOOL_SIZE before any JavaScript runs, so it can only be raised
// from the environment, never from inside the build.
import { availableParallelism } from "node:os";
import { buffer as readStream } from "node:stream/consumers";
import { promisify } from "node:util";
import { brotliCompress } from "node:zlib";
import { ParallelTransform } from "./parallel-transform.mjs";
const EXTENSION = ".br";
const compress = promisify(brotliCompress);
/**
* @param {object} [options]
* @param {boolean} [options.skipLarger] Drop files that compression grows.
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
*/
export default ({ skipLarger = false, params } = {}) =>
new ParallelTransform(availableParallelism(), async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
const compressed = await compress(file.contents, { params });
if (skipLarger && compressed.length >= file.contents.length) {
// Dropped rather than passed through, as gulp-brotli did: the
// uncompressed file is already in the output directory.
return undefined;
}
file.contents = compressed;
file.path += EXTENSION;
return file;
});
+8
View File
@@ -311,7 +311,15 @@ module.exports.config = {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
dashboard: path.resolve(
paths.e2eTestApp_dir,
"src/dashboard-entrypoint.ts"
),
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
onboarding: path.resolve(
paths.e2eTestApp_dir,
"src/onboarding-entrypoint.ts"
),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
+1 -1
View File
@@ -2,7 +2,7 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import brotli from "../brotli.mjs";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
+5 -1
View File
@@ -303,7 +303,11 @@ gulp.task(
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
const E2E_TEST_APP_PAGE_ENTRIES = {
"index.html": ["main"],
"dashboard.html": ["dashboard"],
"onboarding.html": ["onboarding"],
};
gulp.task(
"gen-pages-e2e-test-app-dev",
+64
View File
@@ -0,0 +1,64 @@
// Object-mode transform that keeps several files in flight at once.
//
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which serialises asynchronous work down to one file
// at a time. This keeps `limit` files in flight and applies backpressure beyond
// that. Files are emitted in completion order rather than input order.
import { Transform } from "node:stream";
export class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
+1 -60
View File
@@ -6,9 +6,9 @@
// instance per worker parallelises it; the output bytes are unchanged.
import { availableParallelism } from "node:os";
import { Transform } from "node:stream";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
import { ParallelTransform } from "./parallel-transform.mjs";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
@@ -112,65 +112,6 @@ const sharedPool = () => {
return pool;
};
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which would serialise the pool down to one worker.
// This keeps `limit` files in flight and applies backpressure beyond that.
class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
-304
View File
@@ -1,304 +0,0 @@
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { mainWindow } from "../../../src/common/dom/get_main_window";
import { navigate } from "../../../src/common/navigate";
import "../../../src/components/ha-button";
import "../../../src/components/ha-card";
import "../../../src/components/ha-icon-button";
import "../../../src/components/ha-svg-icon";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "../stubs/cloud-demo-state";
// Walk the DOM, descending into shadow roots, to find the first matching
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
// can ask it to re-fetch after a scenario change.
const deepQuery = (
selector: string,
root: Document | ShadowRoot = document
): Element | null => {
const direct = root.querySelector(selector);
if (direct) {
return direct;
}
const elements = root.querySelectorAll("*");
for (const element of elements) {
const shadow = element.shadowRoot;
if (shadow) {
const found = deepQuery(selector, shadow);
if (found) {
return found;
}
}
}
return null;
};
/**
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
* reviewers can preview every UI state of the cloud account page. It writes to
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
* then nudges the page to re-read it. Lives entirely under demo/.
*/
@customElement("cloud-demo-controls")
export class CloudDemoControls extends LitElement {
@state() private _open = true;
@state() private _visible = false;
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
private _unsub?: () => void;
// The demo uses hash-based routing (navigate() sets location.hash), so the
// active route lives in the hash, not the pathname.
private get _currentPath(): string {
const hash = mainWindow.location.hash;
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
}
private _locationChanged = () => {
this._visible = this._currentPath.startsWith("/config/cloud");
};
public connectedCallback(): void {
super.connectedCallback();
this._locationChanged();
mainWindow.addEventListener("location-changed", this._locationChanged);
mainWindow.addEventListener("popstate", this._locationChanged);
mainWindow.addEventListener("hashchange", this._locationChanged);
this._unsub = subscribeCloudDemoScenario((scenario) => {
this._scenario = { ...scenario };
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._locationChanged);
mainWindow.removeEventListener("popstate", this._locationChanged);
mainWindow.removeEventListener("hashchange", this._locationChanged);
this._unsub?.();
}
protected render() {
if (!this._visible) {
return nothing;
}
if (!this._open) {
return html`
<ha-icon-button
class="fab"
label="Cloud demo controls"
.path=${mdiFlaskOutline}
@click=${this._toggleOpen}
></ha-icon-button>
`;
}
return html`
<ha-card>
<div class="header">
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
<span class="title">Cloud demo controls</span>
<ha-icon-button
label="Close"
.path=${mdiClose}
@click=${this._toggleOpen}
></ha-icon-button>
</div>
<p class="note">
Demo only. Flips the mocked cloud state shown on this page.
</p>
<div class="controls">
${this._segment("Subscription", "account", [
["active", "Active"],
["trialing", "Trialing"],
["canceled", "Canceled"],
["expired", "Expired"],
["unknown", "Unknown"],
])}
${this._toggle("Onboarded", "onboarded")}
${this._toggle("Onboarding postponed", "postponed")}
${this._toggle("Remote access", "remote")}
${this._segment("Remote status", "remoteStatus", [
["ready", "Ready"],
["generating", "Preparing"],
["loading", "Loading"],
["loaded", "Loaded"],
["error", "Error"],
])}
${this._segment("Backups", "backup", [
["fresh", "Recent"],
["stale", "Old"],
["failed", "Failed"],
["local", "Local only"],
["none", "None"],
])}
${this._toggle("Alexa linked", "alexa")}
${this._toggle("Google linked", "google")}
${this._toggle("Cameras (WebRTC)", "webrtc")}
${this._toggle("Has webhooks", "webhooks")}
</div>
</ha-card>
`;
}
private _segment(
label: string,
field: keyof CloudDemoScenario,
options: [string, string][]
) {
return html`
<div class="row">
<span>${label}</span>
<div class="segment">
${options.map(
([value, text]) => html`
<ha-button
size="s"
appearance=${
this._scenario[field] === value ? "filled" : "plain"
}
data-field=${field}
data-value=${value}
@click=${this._segmentClick}
>
${text}
</ha-button>
`
)}
</div>
</div>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<div class="row">
<span>${label}</span>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._toggleChange}
></ha-switch>
</div>
`;
}
private _toggleOpen() {
this._open = !this._open;
}
private _segmentClick(ev: Event) {
const target = ev.currentTarget as HTMLElement;
this._set(
target.dataset.field as keyof CloudDemoScenario,
target.dataset.value!
);
}
private _toggleChange(ev: Event) {
const target = ev.target as HaSwitch;
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
}
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
this._refresh();
}
private _refresh() {
// Refresh the shared cloud status so login-state changes (signed out) and
// status-derived fields update.
const panel = deepQuery("ha-panel-config");
if (panel) {
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
}
// cloud-account fetches its subscription/backup/webhook data once on mount
// and is not cached by the router, so bounce through a sibling cloud route
// to force a clean remount that re-reads the updated mocks.
const path = this._currentPath;
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
const sibling =
path === "/config/cloud/remote"
? "/config/cloud/account"
: "/config/cloud/remote";
navigate(sibling, { replace: true });
window.setTimeout(() => navigate(path, { replace: true }), 0);
}
}
static styles = css`
:host {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9999;
}
.fab {
--mdc-icon-button-size: 48px;
--mdc-icon-size: 24px;
background-color: var(--primary-color);
color: var(--text-primary-color, #fff);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
ha-card {
display: block;
width: 320px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--divider-color);
}
.header .title {
flex: 1;
font-weight: var(--ha-font-weight-medium, 500);
}
.header ha-svg-icon {
color: var(--secondary-text-color);
}
.note {
margin: 8px 16px;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s, 0.875rem);
}
.controls {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
}
.segment {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"cloud-demo-controls": CloudDemoControls;
}
}
+2 -6
View File
@@ -32,7 +32,6 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -59,6 +58,8 @@ const CONFIG_PANEL_COMMANDS = [
"search/related",
"tag/list",
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
];
@customElement("ha-demo")
@@ -90,11 +91,6 @@ export class HaDemo extends HomeAssistantAppEl {
},
});
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
// the document level; it shows itself only on the cloud panel.
if (!document.querySelector("cloud-demo-controls")) {
document.body.appendChild(document.createElement("cloud-demo-controls"));
}
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+25 -109
View File
@@ -7,31 +7,43 @@ import type {
import { BackupScheduleRecurrence } from "../../../src/data/backup";
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { DemoCloudBackup } from "./cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const CLOUD_AGENT = "cloud.cloud";
// Fixed "recent" backup state: an automatic backup completed 12h ago to both the
// local and cloud agents, with the next run scheduled for tomorrow. This is the
// healthy state the cloud overview status line and backup sub-page render.
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const future = new Date(now + 86400000).toISOString();
const backupInfo: BackupInfo = {
backups: [],
backups: [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: recent,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
],
agent_errors: {},
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
next_automatic_backup: null,
next_automatic_backup: future,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
next_automatic_backup: future,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", CLOUD_AGENT],
@@ -61,88 +73,6 @@ const agentsInfo: BackupAgentsInfo = {
],
};
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
// cloud overview status line and the backup sub-page reflect the chosen state.
const applyScenario = () => {
const kind = getCloudDemoScenario().backup;
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
// actually reads as overdue rather than slipping under the margin.
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
// The cloud agent is a backup target for the cloud-backed states only. For
// "local" a backup exists but is stored locally (no cloud copy), and for
// "none" there are no automatic backups at all.
const cloudEnabled =
kind === "fresh" || kind === "stale" || kind === "failed";
backupConfig.create_backup.agent_ids = cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"];
switch (kind) {
case "fresh":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "local":
// Automatic backups run, but only to the local agent.
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "stale":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
backupConfig.last_attempted_automatic_backup = old;
// Next scheduled backup is in the past, so it reads as overdue.
backupConfig.next_automatic_backup = overdue;
break;
case "failed":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
// Most recent attempt is newer than the last success, so it failed.
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "none":
backupConfig.automatic_backups_configured = false;
backupConfig.last_completed_automatic_backup = null;
backupConfig.last_attempted_automatic_backup = null;
backupConfig.next_automatic_backup = null;
break;
}
backupInfo.last_completed_automatic_backup =
backupConfig.last_completed_automatic_backup;
backupInfo.last_attempted_automatic_backup =
backupConfig.last_attempted_automatic_backup;
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
backupInfo.backups =
cloudEnabled && backupConfig.last_completed_automatic_backup
? [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: backupConfig.last_completed_automatic_backup,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
]
: [];
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
export const mockBackup = (hass: MockHomeAssistant) => {
// Fresh objects each fetch so re-reading after a mutation actually re-renders
// (Lit change detection is identity-based; the real WS API returns new
@@ -171,20 +101,6 @@ export const mockBackup = (hass: MockHomeAssistant) => {
if (update.agents) {
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
}
// Reflect the UI-driven backup change into the demo scenario so the demo
// controls panel stays in sync with the mocked state.
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
const current = getCloudDemoScenario().backup;
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
? "none"
: cloudNow
? current === "fresh" || current === "stale" || current === "failed"
? current
: "fresh"
: "local";
if (next !== current) {
setCloudDemoScenario({ backup: next });
}
return null;
});
hass.mockWS(
-89
View File
@@ -1,89 +0,0 @@
// Demo-only switchable Home Assistant Cloud scenario.
//
// The redesigned cloud account page (src/panels/config/cloud/account) renders
// purely from real WS data. To let reviewers preview every UI state without a
// real cloud account, this module holds a mutable "scenario" that the cloud and
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
// to. It is persisted to localStorage so the choice survives the data the page
// fetches once per visit (subscription, backup config, webhooks).
//
// This lives entirely under demo/ — no production code imports it.
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
// The five PaymentSubscriptionState values.
export type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups are configured, but not to the cloud agent
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
export interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
// Onboarding postponed server-side (maps to onboarding_postponed); hides
// the onboarding UI without marking it completed.
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
account: "active",
onboarded: true,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: true,
webhooks: true,
};
const STORAGE_KEY = "cloudDemoScenario";
const readScenario = (): CloudDemoScenario => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
}
} catch (_err) {
// Ignore malformed or unavailable storage and fall back to the default.
}
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
};
let scenario: CloudDemoScenario = readScenario();
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
export const subscribeCloudDemoScenario = (
listener: (scenario: CloudDemoScenario) => void
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const setCloudDemoScenario = (
partial: Partial<CloudDemoScenario>
): void => {
scenario = { ...scenario, ...partial };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
} catch (_err) {
// Ignore storage failures (e.g. private mode); state still applies in-memory.
}
listeners.forEach((listener) => listener(scenario));
};
+24 -107
View File
@@ -6,11 +6,6 @@ import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
import type { Webhook } from "../../../src/data/webhook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const emptyFilter = () => ({
include_domains: [],
@@ -34,14 +29,30 @@ const demoWebhooks: Webhook[] = [
},
];
// A single mutable status object so that preference changes made in the demo
// (both via the real UI and the demo scenario controls) are reflected back.
const demoCloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
// A single mutable status object seeded to one fixed demo state: an active cloud
// subscription with remote ready, cloud backups recent, webhooks set up and voice
// set up (Alexa linked), but cameras (WebRTC) off, so onboarding is still in
// progress (streaming is the remaining step) and not postponed. Page-driven
// changes (connect remote, postpone onboarding, add/delete webhooks) mutate this
// object directly and reset on reload.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: true,
google_registered: false,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: true,
@@ -58,20 +69,20 @@ const cloudStatus: CloudStatusLoggedIn = {
http_use_ssl: false,
active_subscription: true,
onboarding_postponed: false,
onboarding_completed: true,
onboarding_completed: false,
prefs: {
google_enabled: true,
google_enabled: false,
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks: {},
cloudhooks: demoCloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: true,
onboarded_items: [...ONBOARDING_ITEMS],
cloud_ice_servers_enabled: false,
onboarded_items: [],
onboarding_postponed_until: null,
},
};
@@ -93,94 +104,6 @@ const ttsInfo: CloudTTSInfo = {
],
};
// Map the high-level demo scenario onto the mutable cloud status / subscription.
const applyScenario = () => {
const scenario = getCloudDemoScenario();
switch (scenario.account) {
case "trialing":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "trialing" };
break;
case "canceled":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "canceled" };
break;
case "expired":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "expired" };
break;
case "unknown":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "unknown" };
break;
default:
// "active"
cloudStatus.active_subscription = true;
subscription.subscription = { status: "active" };
}
cloudStatus.prefs.onboarded_items = scenario.onboarded
? [...ONBOARDING_ITEMS]
: [];
cloudStatus.onboarding_completed = scenario.onboarded;
cloudStatus.onboarding_postponed = scenario.postponed;
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null;
cloudStatus.prefs.remote_enabled = scenario.remote;
cloudStatus.remote_connected = scenario.remote;
cloudStatus.remote_certificate_status = scenario.remoteStatus;
cloudStatus.alexa_registered = scenario.alexa;
cloudStatus.google_registered = scenario.google;
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
if (scenario.webhooks && !hasCloudhooks) {
cloudStatus.prefs.cloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
} else if (!scenario.webhooks && hasCloudhooks) {
cloudStatus.prefs.cloudhooks = {};
}
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
// back into the demo scenario so the demo controls panel stays in sync with the
// mocked state. Only writes when a value actually changed, to avoid needless
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
// this stays idempotent and does not fight the direct mutation above.
const syncScenarioFromStatus = () => {
const scenario = getCloudDemoScenario();
const next = {
onboarded: cloudStatus.onboarding_completed,
postponed: cloudStatus.onboarding_postponed,
remote: cloudStatus.prefs.remote_enabled,
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
};
if (
scenario.onboarded !== next.onboarded ||
scenario.postponed !== next.postponed ||
scenario.remote !== next.remote ||
scenario.webrtc !== next.webrtc ||
scenario.webhooks !== next.webhooks
) {
setCloudDemoScenario(next);
}
};
export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/status", () => ({
...cloudStatus,
@@ -193,7 +116,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
syncScenarioFromStatus();
return { success: true };
});
@@ -202,7 +124,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
Date.now() + 24 * 3600 * 1000
).toISOString();
cloudStatus.onboarding_postponed = true;
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
@@ -222,7 +143,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
(onboardingItem) =>
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
);
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
@@ -245,20 +165,17 @@ export const mockCloud = (hass: MockHomeAssistant) => {
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
delete cloudhooks[msg.webhook_id];
cloudStatus.prefs.cloudhooks = cloudhooks;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/connect", () => {
cloudStatus.remote_connected = true;
cloudStatus.prefs.remote_enabled = true;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
cloudStatus.remote_connected = false;
cloudStatus.prefs.remote_enabled = false;
syncScenarioFromStatus();
return null;
});
+4
View File
@@ -8,12 +8,14 @@ import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
import { mockExpose } from "./expose";
import { mockNetwork } from "./network";
import { mockPerson } from "./person";
import { mockScene } from "./scene";
import { mockSearch } from "./search";
import { mockSlugify } from "./slugify";
import { mockSystemHealth } from "./system_health";
import { mockTags } from "./tags";
import { mockZone } from "./zone";
@@ -39,4 +41,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockSearch(hass);
mockTags(hass);
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
};
@@ -0,0 +1,22 @@
import type {
EntityRegistrySettings,
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../src/data/entity/entity_registry_settings";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntityRegistrySettings = (hass: MockHomeAssistant) => {
let settings: EntityRegistrySettings = { entity_id_parts: null };
hass.mockWS<typeof fetchEntityRegistrySettings>(
"config/entity_registry/settings/get",
() => settings
);
hass.mockWS<typeof updateEntityRegistrySettings>(
"config/entity_registry/settings/update",
(msg: Partial<EntityRegistrySettings>) => {
settings = { ...settings, ...msg };
return settings;
}
);
};
+9
View File
@@ -0,0 +1,9 @@
import { slugify } from "../../../src/common/string/slugify";
import type { fetchSlug } from "../../../src/data/ws-slugify";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSlugify = (hass: MockHomeAssistant) => {
hass.mockWS<typeof fetchSlug>("slugify", (msg: { text: string }) => ({
slug: slugify(msg.text),
}));
};
+1 -31
View File
@@ -17,9 +17,6 @@ const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
);
// Applies everywhere, including the files exempted from the history rule below.
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
export default tseslint.config(
js.configs.recommended,
eslintConfigPrettier,
@@ -114,16 +111,7 @@ export default tseslint.config(
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": [
"error",
...restrictedSyntax,
{
selector:
"CallExpression[callee.property.name=/^(push|replace)State$/]",
message:
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
},
],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"wc/no-self-class": "off",
// import-x rules
@@ -234,24 +222,6 @@ export default tseslint.config(
],
},
},
{
// These own history entries themselves: the navigation helpers, the dialog
// stack, the boot paths that run before the app has any state to keep, and
// the tests that fabricate entries to simulate a document load.
files: [
"src/common/navigate.ts",
"src/dialogs/make-dialog-manager.ts",
"src/state/url-sync-mixin.ts",
"src/panels/config/automation/add-automation-element-dialog.ts",
"src/entrypoints/core.ts",
"src/onboarding/**/*.ts",
"cast/**/*.ts",
"test/**/*.ts",
],
rules: {
"no-restricted-syntax": ["error", ...restrictedSyntax],
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
@@ -0,0 +1,7 @@
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { getCardElementClass } from "../../../src/panels/lovelace/create-element/create-card-element";
export const validateCardConfig = async (config: LovelaceCardConfig) => {
const cardClass = await getCardElementClass(config.type);
new cardClass().setConfig(config);
};
+40 -9
View File
@@ -1,15 +1,21 @@
import { load } from "js-yaml";
import { dump } from "js-yaml";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../src/components/ha-alert";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { validateCardConfig } from "../common/validate-card-config";
export interface DemoCardConfig {
export interface DemoCardConfig<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
heading: string;
config: string;
config: T;
expectConfigError?: boolean;
}
@customElement("demo-card")
@@ -23,12 +29,29 @@ class DemoCard extends LitElement {
@state() private _size?: number;
@state() private _configError?: string;
@query("hui-card", false) private _card?: HuiCard;
private _config = memoizeOne((config: string) => {
const c = (load(config) as any)[0];
return c;
});
private _yamlConfig = memoizeOne((config: LovelaceCardConfig) =>
dump([config]).trim()
);
protected async firstUpdated() {
try {
await validateCardConfig(this.config.config);
} catch (err) {
if (this.config.expectConfigError) {
return;
}
this._configError = err instanceof Error ? err.message : String(err);
return;
}
if (this.config.expectConfigError) {
this._configError = `Expected config error for ${this.config.heading}`;
}
}
render() {
return html`
@@ -40,15 +63,20 @@ class DemoCard extends LitElement {
: ""
}
</h2>
${
this._configError
? html`<ha-alert alert-type="error">${this._configError}</ha-alert>`
: nothing
}
<div class="root">
<hui-card
.config=${this._config(this.config.config)}
.config=${this.config.config}
.hass=${this.hass}
@card-updated=${this._cardUpdated}
></hui-card>
${
this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
? html`<pre>${this._yamlConfig(this.config.config)}</pre>`
: nothing
}
</div>
@@ -81,6 +109,9 @@ class DemoCard extends LitElement {
font-size: 0.5em;
color: var(--primary-text-color);
}
ha-alert {
margin-bottom: 16px;
}
hui-card {
max-width: 400px;
width: 100vw;
+30 -30
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { AlarmPanelCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -40,52 +42,50 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic Example",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm",
},
},
{
heading: "With Title",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm_armed
name: My Alarm
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm_armed",
name: "My Alarm",
},
},
{
heading: "Code Example",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm_code
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm_code",
},
},
{
heading: "Using only Arm_Home State",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm
states:
- arm_home
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm",
states: ["arm_home"],
},
},
{
heading: "Unavailable",
config: `
- type: alarm-panel
entity: alarm_control_panel.unavailable
states:
- arm_home
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.unavailable",
states: ["arm_home"],
},
},
{
heading: "Invalid Entity",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm1
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm1",
},
},
];
] satisfies DemoCardConfig<AlarmPanelCardConfig>[];
@customElement("demo-lovelace-alarm-panel-card")
class DemoAlarmPanelEntity extends LitElement {
+19 -17
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { AreaCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -80,33 +82,33 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Bedroom",
config: `
- type: area
area: bedroom
`,
config: {
type: "area",
area: "bedroom",
},
},
{
heading: "Living Room",
config: `
- type: area
area: living_room
`,
config: {
type: "area",
area: "living_room",
},
},
{
heading: "Office",
config: `
- type: area
area: office
`,
config: {
type: "area",
area: "office",
},
},
{
heading: "Kitchen",
config: `
- type: area
area: kitchen
`,
config: {
type: "area",
area: "kitchen",
},
},
];
] satisfies DemoCardConfig<AreaCardConfig>[];
@customElement("demo-lovelace-area-card")
class DemoArea extends LitElement {
+32 -25
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
ConditionalCardConfig,
EntitiesCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -39,35 +44,37 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Controller",
config: `
- type: entities
entities:
- light.controller_1
- light.controller_2
- type: divider
- light.floor
- light.kitchen
`,
config: {
type: "entities",
entities: [
"light.controller_1",
"light.controller_2",
{ type: "divider" },
"light.floor",
"light.kitchen",
],
},
},
{
heading: "Demo",
config: `
- type: conditional
conditions:
- entity: light.controller_1
state: "on"
- entity: light.controller_2
state_not: "off"
card:
type: entities
entities:
- light.controller_1
- light.controller_2
- light.floor
- light.kitchen
`,
config: {
type: "conditional",
conditions: [
{ entity: "light.controller_1", state: "on" },
{ entity: "light.controller_2", state_not: "off" },
],
card: {
type: "entities",
entities: [
"light.controller_1",
"light.controller_2",
"light.floor",
"light.kitchen",
],
},
},
},
];
] satisfies DemoCardConfig<EntitiesCardConfig | ConditionalCardConfig>[];
@customElement("demo-lovelace-conditional-card")
class DemoConditional extends LitElement {
+166 -135
View File
@@ -1,7 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
EntitiesCardConfig,
EntitiesCardEntityConfig,
} from "../../../../src/panels/lovelace/cards/types";
import type { CallServiceConfig } from "../../../../src/panels/lovelace/entity-rows/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -254,169 +260,194 @@ const ENTITIES = [
},
];
type GalleryEntitiesCardConfig = Omit<EntitiesCardConfig, "entities"> & {
type: EntitiesCardConfig["type"];
entities: (
| EntitiesCardConfig["entities"][number]
| Pick<EntitiesCardEntityConfig, "entity" | "secondary_info">
| Omit<CallServiceConfig, "entity">
)[];
};
const CONFIGS = [
{
heading: "Basic",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- light.non_existing
- climate.ecobee
- input_number.number
- sensor.humidity
- text.message
- event.doorbell
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"light.non_existing",
"climate.ecobee",
"input_number.number",
"sensor.humidity",
"text.message",
"event.doorbell",
],
},
},
{
heading: "With enabled state color",
config: `
- type: entities
state_color: true
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- light.non_existing
- climate.ecobee
- input_number.number
- sensor.humidity
- text.message
`,
config: {
type: "entities",
state_color: true,
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"light.non_existing",
"climate.ecobee",
"input_number.number",
"sensor.humidity",
"text.message",
],
},
},
{
heading: "Helpers",
config: `
- type: entities
title: Helpers
entities:
- entity: input_boolean.toggle
- entity: input_datetime.date_and_time
- entity: input_number.number
- entity: input_select.dropdown
- entity: input_text.text
- entity: timer.timer
- entity: counter.counter
`,
config: {
type: "entities",
title: "Helpers",
entities: [
{ entity: "input_boolean.toggle" },
{ entity: "input_datetime.date_and_time" },
{ entity: "input_number.number" },
{ entity: "input_select.dropdown" },
{ entity: "input_text.text" },
{ entity: "timer.timer" },
{ entity: "counter.counter" },
],
},
},
{
heading: "With title, toggle-able",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- climate.ecobee
- input_number.number
title: Random group
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"climate.ecobee",
"input_number.number",
],
title: "Random group",
},
},
{
heading: "With title, toggle = false",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- climate.ecobee
- input_number.number
title: Random group
show_header_toggle: false
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"climate.ecobee",
"input_number.number",
],
title: "Random group",
show_header_toggle: false,
},
},
{
heading: "With title, can't toggle",
config: `
- type: entities
entities:
- device_tracker.demo_paulus
title: Random group
`,
config: {
type: "entities",
entities: ["device_tracker.demo_paulus"],
title: "Random group",
},
},
{
heading: "Unavailable",
config: `
- type: entities
entities:
- scene.unavailable
- device_tracker.unavailable
- cover.unavailable
- lock.unavailable
- light.unavailable
- climate.unavailable
- input_number.unavailable
- input_select.unavailable
- text.unavailable
- event.unavailable
`,
config: {
type: "entities",
entities: [
"scene.unavailable",
"device_tracker.unavailable",
"cover.unavailable",
"lock.unavailable",
"light.unavailable",
"climate.unavailable",
"input_number.unavailable",
"input_select.unavailable",
"text.unavailable",
"event.unavailable",
],
},
},
{
heading: "Custom name, secondary info, custom icon",
config: `
- type: entities
entities:
- entity: scene.romantic_lights
name: ¯\\_(ツ)_/¯
- entity: device_tracker.demo_paulus
secondary_info: entity-id
- entity: cover.kitchen_window
secondary_info: last-changed
- entity: group.kitchen
icon: mdi:home-assistant
- lock.kitchen_door
- entity: light.bed_light
icon: mdi:alarm-light
name: Bed Light Custom Icon
- climate.ecobee
- input_number.number
title: Random group
show_header_toggle: false
`,
config: {
type: "entities",
entities: [
{ entity: "scene.romantic_lights", name: "¯\\_(ツ)_/¯" },
{
entity: "device_tracker.demo_paulus",
secondary_info: "entity-id",
},
{
entity: "cover.kitchen_window",
secondary_info: "last-changed",
},
{ entity: "group.kitchen", icon: "mdi:home-assistant" },
"lock.kitchen_door",
{
entity: "light.bed_light",
icon: "mdi:alarm-light",
name: "Bed Light Custom Icon",
},
"climate.ecobee",
"input_number.number",
],
title: "Random group",
show_header_toggle: false,
},
},
{
heading: "Special rows",
config: `
- type: entities
entities:
- type: perform-action
icon: mdi:power
name: Bed light
action_name: Toggle light
action: light.toggle
data:
entity_id: light.bed_light
- type: section
label: Links
- type: weblink
url: http://google.com/
icon: mdi:google
name: Google
- type: divider
- type: divider
style:
height: 30px
margin: 4px 0
background: center / contain url("/images/divider.png") no-repeat
`,
config: {
type: "entities",
entities: [
{
type: "perform-action",
icon: "mdi:power",
name: "Bed light",
action_name: "Toggle light",
action: "light.toggle",
data: { entity_id: "light.bed_light" },
},
{ type: "section", label: "Links" },
{
type: "weblink",
url: "http://google.com/",
icon: "mdi:google",
name: "Google",
},
{ type: "divider" },
{
type: "divider",
style: {
height: "30px",
margin: "4px 0",
background: 'center / contain url("/images/divider.png") no-repeat',
},
},
],
},
},
];
] satisfies DemoCardConfig<GalleryEntitiesCardConfig>[];
@customElement("demo-lovelace-entities-card")
class DemoEntities extends LitElement {
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { ButtonCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -18,60 +20,64 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: button
entity: light.bed_light
`,
config: {
type: "button",
entity: "light.bed_light",
},
},
{
heading: "With Name (defined in card)",
config: `
- type: button
name: Custom Name
entity: light.bed_light
`,
config: {
type: "button",
name: "Custom Name",
entity: "light.bed_light",
},
},
{
heading: "With Icon",
config: `
- type: button
entity: light.bed_light
icon: mdi:tools
`,
config: {
type: "button",
entity: "light.bed_light",
icon: "mdi:tools",
},
},
{
heading: "With State",
config: `
- type: button
entity: light.bed_light
show_state: true
`,
config: {
type: "button",
entity: "light.bed_light",
show_state: true,
},
},
{
heading: "Custom Tap Action (toggle)",
config: `
- type: button
entity: light.bed_light
tap_action:
action: toggle
`,
config: {
type: "button",
entity: "light.bed_light",
tap_action: {
action: "toggle",
},
},
},
{
heading: "Running Service",
config: `
- type: button
entity: light.bed_light
service: light.toggle
`,
config: {
type: "button",
entity: "light.bed_light",
tap_action: {
action: "perform-action",
perform_action: "light.toggle",
},
},
},
{
heading: "Invalid Entity",
config: `
- type: button
entity: sensor.invalid_entity
`,
config: {
type: "button",
entity: "sensor.invalid_entity",
},
},
];
] satisfies DemoCardConfig<ButtonCardConfig>[];
@customElement("demo-lovelace-entity-button-card")
class DemoButtonEntity extends LitElement {
+160 -141
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
EntitiesCardConfig,
EntityFilterCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -114,184 +119,198 @@ const ENTITIES = [
},
];
const CONFIGS = [
type StateFilterEntityFilterCardConfig = Pick<
EntityFilterCardConfig,
"type" | "entities" | "card" | "show_empty"
> & {
conditions?: never;
state_filter: NonNullable<EntityFilterCardConfig["state_filter"]>;
};
const VALID_CONFIGS = [
{
heading: "Unfiltered entities",
config: `
- type: entities
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
`,
config: {
type: "entities",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
},
},
{
heading: "On and home entities",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
},
},
{
heading: "Same state as Bed Light",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- light.bed_light
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["light.bed_light"] }],
},
},
{
heading: 'With "entities" card config',
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
card:
type: entities
title: Custom Title
show_header_toggle: false
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
card: {
type: "entities",
title: "Custom Title",
show_header_toggle: false,
},
},
},
{
heading: 'With "glance" card config',
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
card:
type: glance
show_state: true
title: Custom Title
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
card: {
type: "glance",
show_state: true,
title: "Custom Title",
},
},
},
{
heading:
"Filtered entities by battery attribute (< '30') using state filter",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
state_filter:
- operator: <
attribute: battery
value: "30"
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
state_filter: [{ operator: "<", attribute: "battery", value: "30" }],
},
},
{
heading: "Unfiltered number entities",
config: `
- type: entities
entities:
- input_number.min_battery_level
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
`,
config: {
type: "entities",
entities: [
"input_number.min_battery_level",
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
},
},
{
heading: "Battery lower than 50%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: 50
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [{ condition: "numeric_state", below: 50 }],
},
},
{
heading: "Battery lower than min battery level",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: input_number.min_battery_level
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [
{ condition: "numeric_state", below: "input_number.min_battery_level" },
],
},
},
{
heading: "Battery between min battery level and 70%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
above: input_number.min_battery_level
below: 70
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [
{
condition: "numeric_state",
above: "input_number.min_battery_level",
below: 70,
},
],
},
},
] satisfies DemoCardConfig<
| EntitiesCardConfig
| EntityFilterCardConfig
| StateFilterEntityFilterCardConfig
>[];
const INVALID_CONFIGS = [
{
heading: "Error: Entities must be specified",
config: `
- type: entity-filter
`,
config: { type: "entity-filter" },
expectConfigError: true,
},
{
heading: "Error: Incorrect filter config",
config: `
- type: entity-filter
entities:
- sensor.gas_station_lowest_price
`,
config: {
type: "entity-filter",
entities: ["sensor.gas_station_lowest_price"],
},
expectConfigError: true,
},
];
] satisfies DemoCardConfig<
| Pick<EntityFilterCardConfig, "type">
| Pick<EntityFilterCardConfig, "type" | "entities">
>[];
const CONFIGS = [...VALID_CONFIGS, ...INVALID_CONFIGS];
@customElement("demo-lovelace-entity-filter-card")
class DemoEntityFilter extends LitElement {
+115 -115
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { GaugeCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -30,158 +32,156 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: gauge
entity: sensor.outside_humidity
name: Outside Humidity
`,
config: {
type: "gauge",
entity: "sensor.outside_humidity",
name: "Outside Humidity",
},
},
{
heading: "Custom unit of measurement",
config: `
- type: gauge
entity: sensor.outside_temperature
unit_of_measurement: C
name: Outside Temperature
`,
config: {
type: "gauge",
entity: "sensor.outside_temperature",
unit: "C",
name: "Outside Temperature",
},
},
{
heading: "Rendering needle",
config: `
- type: gauge
entity: sensor.outside_humidity
name: Outside Humidity
needle: true
`,
config: {
type: "gauge",
entity: "sensor.outside_humidity",
name: "Outside Humidity",
needle: true,
},
},
{
heading: "Rendering needle and severity levels",
config: `
- type: gauge
entity: sensor.brightness_high
name: Brightness High
needle: true
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
name: "Brightness High",
needle: true,
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness
name: Brightness Low
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness",
name: "Brightness Low",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness_medium
name: Brightness Medium
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_medium",
name: "Brightness Medium",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness_high
name: Brightness High
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
name: "Brightness High",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting min (0) and mx (15) values",
config: `
- type: gauge
entity: sensor.brightness
name: Brightness
min: 0
max: 15
`,
config: {
type: "gauge",
entity: "sensor.brightness",
name: "Brightness",
min: 0,
max: 15,
},
},
{
heading: "Invalid entity",
config: `
- type: gauge
entity: sensor.invalid_entity
`,
config: {
type: "gauge",
entity: "sensor.invalid_entity",
},
},
{
heading: "Non-numeric value",
config: `
- type: gauge
entity: plant.bonsai
`,
config: {
type: "gauge",
entity: "plant.bonsai",
},
},
{
heading: "Unavailable entity",
config: `
- type: gauge
entity: sensor.not_working
`,
config: {
type: "gauge",
entity: "sensor.not_working",
},
},
{
heading: "Lower minimum",
config: `
- type: gauge
entity: sensor.brightness_high
needle: true
severity:
green: 0
yellow: 0.45
red: 0.9
min: -0.05
name: " "
max: 1.9
unit: GBP/h`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
needle: true,
severity: {
green: 0,
yellow: 0.45,
red: 0.9,
},
min: -0.05,
name: " ",
max: 1.9,
unit: "GBP/h",
},
},
{
heading: "A lot of segments",
config: `
- type: gauge
needle: true
name: Percent gauge
entity: sensor.brightness_high
unit: "%"
min: 0
max: 100
segments:
- from: 0
color: "#db4437"
- from: 10
color: "#cc4d39"
- from: 20
color: "#bd563a"
- from: 30
color: "#ad603c"
- from: 40
color: "#9e693d"
- from: 50
color: "#8f723f"
- from: 60
color: "#807b41"
- from: 70
color: "#718442"
- from: 80
color: "#618e44"
- from: 90
color: "#43a047"`,
config: {
type: "gauge",
needle: true,
name: "Percent gauge",
entity: "sensor.brightness_high",
unit: "%",
min: 0,
max: 100,
segments: [
{ from: 0, color: "#db4437" },
{ from: 10, color: "#cc4d39" },
{ from: 20, color: "#bd563a" },
{ from: 30, color: "#ad603c" },
{ from: 40, color: "#9e693d" },
{ from: 50, color: "#8f723f" },
{ from: 60, color: "#807b41" },
{ from: 70, color: "#718442" },
{ from: 80, color: "#618e44" },
{ from: 90, color: "#43a047" },
],
},
},
];
] satisfies DemoCardConfig<GaugeCardConfig>[];
@customElement("demo-lovelace-gauge-card")
class DemoGaugeEntity extends LitElement {
+161 -135
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GlanceCardConfig,
GlanceConfigEntity,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -86,172 +91,193 @@ const ENTITIES = [
},
];
type LegacyNullNameGlanceCardConfig = Omit<GlanceCardConfig, "entities"> & {
type: GlanceCardConfig["type"];
entities: (
| string
| GlanceConfigEntity
| (Omit<GlanceConfigEntity, "name"> & { name: null })
)[];
};
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: glance
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No state colors",
config: `
- type: glance
state_color: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
state_color: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "With title",
config: `
- type: glance
title: Custom title
columns: 4
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
title: "Custom title",
columns: 4,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "Custom number of columns",
config: `
- type: glance
columns: 7
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 7,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No entity names",
config: `
- type: glance
columns: 4
show_name: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_name: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No state labels",
config: `
- type: glance
columns: 4
show_state: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_state: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No names and no state labels",
config: `
- type: glance
columns: 4
show_name: false
show_state: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_name: false,
show_state: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "Custom name + custom icon",
config: `
- type: glance
columns: 4
entities:
- entity: device_tracker.demo_paulus
name: ¯\\_(ツ)_/¯
icon: mdi:home-assistant
- entity: media_player.living_room
name: ¯\\_(ツ)_/¯
icon: mdi:home-assistant
`,
config: {
type: "glance",
columns: 4,
entities: [
{
entity: "device_tracker.demo_paulus",
name: "¯\\_(ツ)_/¯",
icon: "mdi:home-assistant",
},
{
entity: "media_player.living_room",
name: "¯\\_(ツ)_/¯",
icon: "mdi:home-assistant",
},
],
},
},
{
heading: "Selectively hidden name",
config: `
- type: glance
columns: 4
entities:
- device_tracker.demo_paulus
- entity: media_player.living_room
name:
- sun.sun
- entity: cover.kitchen_window
name:
- light.kitchen_lights
- entity: lock.kitchen_door
name:
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
entities: [
"device_tracker.demo_paulus",
{ entity: "media_player.living_room", name: null },
"sun.sun",
{ entity: "cover.kitchen_window", name: null },
"light.kitchen_lights",
{ entity: "lock.kitchen_door", name: null },
"light.ceiling_lights",
],
},
},
{
heading: "Custom tap action",
config: `
- type: glance
columns: 4
entities:
- entity: lock.kitchen_door
name: Custom
tap_action:
type: toggle
- entity: light.ceiling_lights
name: Custom
tap_action:
action: call-service
service: light.turn_on
data:
entity_id: light.ceiling_lights
- entity: sun.sun
name: Regular
- entity: light.kitchen_lights
name: Regular
`,
config: {
type: "glance",
columns: 4,
entities: [
{
entity: "lock.kitchen_door",
name: "Custom",
tap_action: { action: "toggle" },
},
{
entity: "light.ceiling_lights",
name: "Custom",
tap_action: {
action: "perform-action",
perform_action: "light.turn_on",
data: { entity_id: "light.ceiling_lights" },
},
},
{ entity: "sun.sun", name: "Regular" },
{ entity: "light.kitchen_lights", name: "Regular" },
],
},
},
];
] satisfies DemoCardConfig<GlanceCardConfig | LegacyNullNameGlanceCardConfig>[];
@customElement("demo-lovelace-glance-card")
class DemoGlanceEntity extends LitElement {
+129 -124
View File
@@ -1,8 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GridCardConfig,
StackCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -70,159 +75,159 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Default Grid",
config: `
- type: grid
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
- type: entity
entity: device_tracker.demo_anne_therese
`,
config: {
type: "grid",
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
],
},
},
{
heading: "Non-square Grid with 2 columns",
config: `
- type: grid
columns: 2
square: false
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
`,
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
],
},
},
{
heading: "Default Grid with title",
config: `
- type: grid
title: Kitchen
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
- type: entity
entity: device_tracker.demo_anne_therese
`,
config: {
type: "grid",
title: "Kitchen",
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
],
},
},
{
heading: "Columns 4",
config: `
- type: grid
columns: 4
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
`,
config: {
type: "grid",
columns: 4,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
],
},
},
{
heading: "Columns 2",
config: `
- type: grid
columns: 2
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
`,
config: {
type: "grid",
columns: 2,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
],
},
},
{
heading: "Columns 1",
config: `
- type: grid
columns: 1
cards:
- type: entity
entity: light.kitchen_lights
`,
config: {
type: "grid",
columns: 1,
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
},
},
{
heading: "Size for single card",
config: `
- type: grid
cards:
- type: entity
entity: light.kitchen_lights
`,
config: {
type: "grid",
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
},
},
{
heading: "Vertical Stack",
config: `
- type: vertical-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
`,
config: {
type: "vertical-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
},
{
heading: "Horizontal Stack",
config: `
- type: horizontal-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
`,
config: {
type: "horizontal-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
},
{
heading: "Combination of both",
config: `
- type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- type: picture-entity
image: /images/bed.png
entity: light.bed_light
`,
config: {
type: "vertical-stack",
cards: [
{
type: "horizontal-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
{
type: "picture-entity",
image: "/images/bed.png",
entity: "light.bed_light",
},
],
},
},
];
] satisfies DemoCardConfig<GridCardConfig | StackCardConfig>[];
@customElement("demo-lovelace-grid-and-stack-card")
class DemoStack extends LitElement {
+22 -20
View File
@@ -1,42 +1,44 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { IframeCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
const CONFIGS = [
{
heading: "Without title",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
},
},
{
heading: "With title",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
title: Weather radar
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
title: "Weather radar",
},
},
{
heading: "Height-Width 3:4",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
aspect_ratio: 75%
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
aspect_ratio: "75%",
},
},
{
heading: "Height-Width 1:1",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
aspect_ratio: 100%
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
aspect_ratio: "100%",
},
},
];
] satisfies DemoCardConfig<IframeCardConfig>[];
@customElement("demo-lovelace-iframe-card")
class DemoIframe extends LitElement {
+23 -21
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { LightCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -44,40 +46,40 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Switchable Light",
config: `
- type: light
entity: light.bed_light
`,
config: {
type: "light",
entity: "light.bed_light",
},
},
{
heading: "Dimmable Light On",
config: `
- type: light
entity: light.dim_on
`,
config: {
type: "light",
entity: "light.dim_on",
},
},
{
heading: "Dimmable Light Off",
config: `
- type: light
entity: light.dim_off
`,
config: {
type: "light",
entity: "light.dim_off",
},
},
{
heading: "Unavailable",
config: `
- type: light
entity: light.unavailable
`,
config: {
type: "light",
entity: "light.unavailable",
},
},
{
heading: "Non existing",
config: `
- type: light
entity: light.nonexisting
`,
config: {
type: "light",
entity: "light.nonexisting",
},
},
];
] satisfies DemoCardConfig<LightCardConfig>[];
@customElement("demo-lovelace-light-card")
class DemoLightEntity extends LitElement {
+66 -70
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MapCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
const ENTITIES = [
@@ -86,107 +88,101 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Without title",
config: `
- type: map
entities:
- entity: device_tracker.demo_paulus
- device_tracker.demo_home_boy
- zone.home
`,
config: {
type: "map",
entities: [
{ entity: "device_tracker.demo_paulus" },
"device_tracker.demo_home_boy",
"zone.home",
],
},
},
{
heading: "With title",
config: `
- type: map
entities:
- entity: device_tracker.demo_paulus
- zone.home
title: Where is Paulus?
`,
config: {
type: "map",
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
title: "Where is Paulus?",
},
},
{
heading: "Height-Width 1:2",
config: `
- type: map
entities:
- entity: device_tracker.demo_paulus
- zone.home
aspect_ratio: 50%
`,
config: {
type: "map",
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
aspect_ratio: "50%",
},
},
{
heading: "Default Zoom",
config: `
- type: map
default_zoom: 12
entities:
- entity: device_tracker.demo_paulus
- zone.home
`,
config: {
type: "map",
default_zoom: 12,
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
},
},
{
heading: "Default Zoom too High",
config: `
- type: map
default_zoom: 20
entities:
- entity: device_tracker.demo_paulus
- zone.home
`,
config: {
type: "map",
default_zoom: 20,
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
},
},
{
heading: "Single Marker",
config: `
- type: map
entities:
- device_tracker.demo_paulus
`,
config: {
type: "map",
entities: ["device_tracker.demo_paulus"],
},
},
{
heading: "Single Marker Default Zoom",
config: `
- type: map
default_zoom: 8
entities:
- device_tracker.demo_paulus
`,
config: {
type: "map",
default_zoom: 8,
entities: ["device_tracker.demo_paulus"],
},
},
{
heading: "No Entities",
config: `
- type: map
entities:
- light.bed_light
`,
config: {
type: "map",
entities: ["light.bed_light"],
},
},
{
heading: "No Entities, Default Zoom",
config: `
- type: map
default_zoom: 8
entities:
- light.bed_light
`,
config: {
type: "map",
default_zoom: 8,
entities: ["light.bed_light"],
},
},
{
heading: "Geo Location Entities",
config: `
- type: map
geo_location_sources:
- bushfire_demo
`,
config: {
type: "map",
geo_location_sources: ["bushfire_demo"],
},
},
{
heading: "Geo Location Entities with Home Zone",
config: `
- type: map
geo_location_sources:
- bushfire_demo
entities:
- zone.bushfire
`,
config: {
type: "map",
geo_location_sources: ["bushfire_demo"],
entities: ["zone.bushfire"],
},
},
];
{
heading: "Scale ruler",
config: {
type: "map",
scale_ruler: true,
entities: ["zone.home"],
},
},
] satisfies DemoCardConfig<MapCardConfig>[];
@customElement("demo-lovelace-map-card")
class DemoMap extends LitElement {
+10 -6
View File
@@ -1,17 +1,18 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { mockTemplate } from "../../../../demo/src/stubs/template";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MarkdownCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
const CONFIGS = [
{
heading: "markdown-it demo",
config: `
- type: markdown
content: |
# h1 Heading 8-)
config: {
type: "markdown",
content: `# h1 Heading 8-)
## h2 Heading
@@ -278,9 +279,12 @@ const CONFIGS = [
<ha-alert alert-type="success">This is a success alert — check it out!</ha-alert>
<ha-alert title="Test alert">This is an alert with a title</ha-alert>
`,
`
.replace(/^ {4}/gm, "")
.replace(/\n+$/, "\n"),
},
},
];
] satisfies DemoCardConfig<MarkdownCardConfig>[];
@customElement("demo-lovelace-markdown-card")
class DemoMarkdown extends LitElement {
@@ -1,162 +1,165 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GridCardConfig,
MediaControlCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { createMediaPlayerEntities } from "../../data/media_players";
const CONFIGS = [
{
heading: "Paused Music",
config: `
- type: media-control
entity: media_player.music_paused
`,
config: {
type: "media-control",
entity: "media_player.music_paused",
},
},
{
heading: "Playing Music",
config: `
- type: media-control
entity: media_player.music_playing
`,
config: {
type: "media-control",
entity: "media_player.music_playing",
},
},
{
heading: "Playing Stream",
config: `
- type: media-control
entity: media_player.stream_playing
`,
config: {
type: "media-control",
entity: "media_player.stream_playing",
},
},
{
heading: "Paused Stream",
config: `
- type: media-control
entity: media_player.stream_paused
`,
config: {
type: "media-control",
entity: "media_player.stream_paused",
},
},
{
heading: 'Playing Stream (with "previous" support)',
config: `
- type: media-control
entity: media_player.stream_playing_previous
`,
config: {
type: "media-control",
entity: "media_player.stream_playing_previous",
},
},
{
heading: "Playing non-skip TV Show",
config: `
- type: media-control
entity: media_player.tv_playing
`,
config: {
type: "media-control",
entity: "media_player.tv_playing",
},
},
{
heading: "Screen Casting",
config: `
- type: media-control
entity: media_player.android_cast
`,
config: {
type: "media-control",
entity: "media_player.android_cast",
},
},
{
heading: "Digital Picture Frame",
config: `
- type: media-control
entity: media_player.image_display
`,
config: {
type: "media-control",
entity: "media_player.image_display",
},
},
{
heading: "Sonos Idle",
config: `
- type: media-control
entity: media_player.sonos_idle
`,
config: {
type: "media-control",
entity: "media_player.sonos_idle",
},
},
{
heading: "Idle waiting for Browse Media",
config: `
- type: media-control
entity: media_player.idle_browse_media
`,
config: {
type: "media-control",
entity: "media_player.idle_browse_media",
},
},
{
heading: "Player Off",
config: `
- type: media-control
entity: media_player.theater_off
`,
config: {
type: "media-control",
entity: "media_player.theater_off",
},
},
{
heading: "Player On",
config: `
- type: media-control
entity: media_player.theater_on
`,
config: {
type: "media-control",
entity: "media_player.theater_on",
},
},
{
heading: "Player Off (cannot be switched on)",
config: `
- type: media-control
entity: media_player.theater_off_static
`,
config: {
type: "media-control",
entity: "media_player.theater_off_static",
},
},
{
heading: "Player On (cannot be switched off)",
config: `
- type: media-control
entity: media_player.theater_on_static
`,
config: {
type: "media-control",
entity: "media_player.theater_on_static",
},
},
{
heading: "Player Idle",
config: `
- type: media-control
entity: media_player.idle
`,
config: {
type: "media-control",
entity: "media_player.idle",
},
},
{
heading: "Player Playing",
config: `
- type: media-control
entity: media_player.playing
`,
config: {
type: "media-control",
entity: "media_player.playing",
},
},
{
heading: "Player Unavailable",
config: `
- type: media-control
entity: media_player.unavailable
`,
config: {
type: "media-control",
entity: "media_player.unavailable",
},
},
{
heading: "Player Unknown",
config: `
- type: media-control
entity: media_player.unknown
`,
config: {
type: "media-control",
entity: "media_player.unknown",
},
},
{
heading: "Receiver On (selectable sources)",
config: `
- type: media-control
entity: media_player.receiver_on
`,
config: {
type: "media-control",
entity: "media_player.receiver_on",
},
},
{
heading: "Receiver Off (selectable sources)",
config: `
- type: media-control
entity: media_player.receiver_off
`,
config: {
type: "media-control",
entity: "media_player.receiver_off",
},
},
{
heading: "Grid Full Size",
config: `
- type: grid
columns: 1
cards:
- type: media-control
entity: media_player.music_paused
`,
config: {
type: "grid",
columns: 1,
cards: [{ type: "media-control", entity: "media_player.music_paused" }],
},
},
];
] satisfies DemoCardConfig<MediaControlCardConfig | GridCardConfig>[];
@customElement("demo-lovelace-media-control-card")
class DemoHuiMediaControlCard extends LitElement {
+46 -45
View File
@@ -1,59 +1,60 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { EntitiesCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { createMediaPlayerEntities } from "../../data/media_players";
const CONFIGS = [
{
heading: "Media Players",
config: `
- type: entities
entities:
- entity: media_player.music_paused
name: Paused Music
- entity: media_player.music_playing
name: Playing Music
- entity: media_player.stream_playing
name: Playing Stream
- entity: media_player.stream_paused
name: Paused Stream
- entity: media_player.stream_playing_previous
name: Playing Stream (with "previous" support)
- entity: media_player.tv_playing
name: Playing non-skip TV Show
- entity: media_player.android_cast
name: Screen casting
- entity: media_player.image_display
name: Digital Picture Frame
- entity: media_player.sonos_idle
name: Sonos Idle
- entity: media_player.idle_browse_media
name: Idle waiting for Browse Media
- entity: media_player.theater_off
name: Player Off
- entity: media_player.theater_on
name: Player On
- entity: media_player.theater_off_static
name: Player Off (cannot be switched on)
- entity: media_player.theater_on_static
name: Player On (cannot be switched off)
- entity: media_player.idle
name: Player Idle
- entity: media_player.playing
name: Player Playing
- entity: media_player.unavailable
name: Player Unavailable
- entity: media_player.unknown
name: Player Unknown
- entity: media_player.receiver_on
name: Receiver On (selectable sources)
- entity: media_player.receiver_off
name: Receiver Off (selectable sources)
`,
config: {
type: "entities",
entities: [
{ entity: "media_player.music_paused", name: "Paused Music" },
{ entity: "media_player.music_playing", name: "Playing Music" },
{ entity: "media_player.stream_playing", name: "Playing Stream" },
{ entity: "media_player.stream_paused", name: "Paused Stream" },
{
entity: "media_player.stream_playing_previous",
name: 'Playing Stream (with "previous" support)',
},
{ entity: "media_player.tv_playing", name: "Playing non-skip TV Show" },
{ entity: "media_player.android_cast", name: "Screen casting" },
{ entity: "media_player.image_display", name: "Digital Picture Frame" },
{ entity: "media_player.sonos_idle", name: "Sonos Idle" },
{
entity: "media_player.idle_browse_media",
name: "Idle waiting for Browse Media",
},
{ entity: "media_player.theater_off", name: "Player Off" },
{ entity: "media_player.theater_on", name: "Player On" },
{
entity: "media_player.theater_off_static",
name: "Player Off (cannot be switched on)",
},
{
entity: "media_player.theater_on_static",
name: "Player On (cannot be switched off)",
},
{ entity: "media_player.idle", name: "Player Idle" },
{ entity: "media_player.playing", name: "Player Playing" },
{ entity: "media_player.unavailable", name: "Player Unavailable" },
{ entity: "media_player.unknown", name: "Player Unknown" },
{
entity: "media_player.receiver_on",
name: "Receiver On (selectable sources)",
},
{
entity: "media_player.receiver_off",
name: "Receiver Off (selectable sources)",
},
],
},
},
];
] satisfies DemoCardConfig<EntitiesCardConfig>[];
@customElement("demo-lovelace-media-player-row")
export class DemoLovelaceMediaPlayerRow extends LitElement {
+15 -13
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { PictureCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -19,26 +21,26 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Image URL",
config: `
- type: picture
image: /images/living_room.png
`,
config: {
type: "picture",
image: "/images/living_room.png",
},
},
{
heading: "Person entity",
config: `
- type: picture
image_entity: person.paulus
`,
config: {
type: "picture",
image_entity: "person.paulus",
},
},
{
heading: "Error: Image required",
config: `
- type: picture
entity: person.paulus
`,
config: {
type: "picture",
},
expectConfigError: true,
},
];
] satisfies DemoCardConfig<PictureCardConfig>[];
@customElement("demo-lovelace-picture-card")
class DemoPicture extends LitElement {
@@ -1,7 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureElementsCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type {
ImageElementConfig,
LovelaceElementConfig,
} from "../../../../src/panels/lovelace/elements/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -60,116 +66,141 @@ const ENTITIES = [
},
];
type LegacyImageElementConfig = Omit<
ImageElementConfig,
"state_filter" | "state_image"
> & {
state_filter?: Record<string, string>;
state_image?: Record<string, string>;
};
type GalleryPictureElementsCardConfig = Omit<
PictureElementsCardConfig,
"elements"
> & {
type: PictureElementsCardConfig["type"];
elements: (LovelaceElementConfig | LegacyImageElementConfig)[];
};
const CONFIGS = [
{
heading: "Card with few elements",
config: `
- type: picture-elements
image: /images/floorplan.png
elements:
- type: service-button
title: Lights Off
style:
top: 97%
left: 90%
padding: 0px
service: light.turn_off
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
entity: camera.demo_camera
style:
top: 12%
left: 6%
transform: rotate(-60deg) scaleX(-1)
--mdc-icon-size: 30px
--mdc-icon-stroke-color: black
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
- type: image
entity: light.bed_light
tap_action:
action: toggle
image: /images/light_bulb_off.png
state_image:
'on': /images/light_bulb_on.png
state_filter:
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
'off': brightness(80%) saturate(0.8)
style:
top: 35%
left: 65%
width: 7%
padding: 50px 50px 100px 50px
- type: state-icon
entity: binary_sensor.movement_backyard
style:
top: 8%
left: 35%
`,
config: {
type: "picture-elements",
image: "/images/floorplan.png",
elements: [
{
type: "service-button",
title: "Lights Off",
style: { top: "97%", left: "90%", padding: "0px" },
service: "light.turn_off",
data: { entity_id: "group.all_lights" },
},
{
type: "icon",
icon: "mdi:cctv",
entity: "camera.demo_camera",
style: {
top: "12%",
left: "6%",
transform: "rotate(-60deg) scaleX(-1)",
"--mdc-icon-size": "30px",
"--mdc-icon-stroke-color": "black",
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
},
},
{
type: "image",
entity: "light.bed_light",
tap_action: { action: "toggle" },
image: "/images/light_bulb_off.png",
state_image: { on: "/images/light_bulb_on.png" },
state_filter: {
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
off: "brightness(80%) saturate(0.8)",
},
style: {
top: "35%",
left: "65%",
width: "7%",
padding: "50px 50px 100px 50px",
},
},
{
type: "state-icon",
entity: "binary_sensor.movement_backyard",
style: { top: "8%", left: "35%" },
},
],
},
},
{
heading: "Card with header",
config: `
- type: picture-elements
image: /images/floorplan.png
title: My House
elements:
- type: service-button
title: Lights Off
style:
top: 97%
left: 90%
padding: 0px
service: light.turn_off
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
entity: camera.demo_camera
style:
top: 12%
left: 6%
transform: rotate(-60deg) scaleX(-1)
--mdc-icon-size: 30px
--mdc-icon-stroke-color: black
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
- type: image
entity: light.bed_light
tap_action:
action: toggle
image: /images/light_bulb_off.png
state_image:
'on': /images/light_bulb_on.png
state_filter:
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
'off': brightness(80%) saturate(0.8)
style:
top: 35%
left: 65%
width: 7%
padding: 50px 50px 100px 50px
- type: state-icon
entity: binary_sensor.movement_backyard
style:
top: 8%
left: 35%
`,
config: {
type: "picture-elements",
image: "/images/floorplan.png",
title: "My House",
elements: [
{
type: "service-button",
title: "Lights Off",
style: { top: "97%", left: "90%", padding: "0px" },
service: "light.turn_off",
data: { entity_id: "group.all_lights" },
},
{
type: "icon",
icon: "mdi:cctv",
entity: "camera.demo_camera",
style: {
top: "12%",
left: "6%",
transform: "rotate(-60deg) scaleX(-1)",
"--mdc-icon-size": "30px",
"--mdc-icon-stroke-color": "black",
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
},
},
{
type: "image",
entity: "light.bed_light",
tap_action: { action: "toggle" },
image: "/images/light_bulb_off.png",
state_image: { on: "/images/light_bulb_on.png" },
state_filter: {
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
off: "brightness(80%) saturate(0.8)",
},
style: {
top: "35%",
left: "65%",
width: "7%",
padding: "50px 50px 100px 50px",
},
},
{
type: "state-icon",
entity: "binary_sensor.movement_backyard",
style: { top: "8%", left: "35%" },
},
],
},
},
{
heading: "Person entity",
config: `
- type: picture-elements
image_entity: person.paulus
elements:
- type: state-icon
entity: sensor.battery
style:
top: 8%
left: 8%
`,
config: {
type: "picture-elements",
image_entity: "person.paulus",
elements: [
{
type: "state-icon",
entity: "sensor.battery",
style: { top: "8%", left: "8%" },
},
],
},
},
];
] satisfies DemoCardConfig<GalleryPictureElementsCardConfig>[];
@customElement("demo-lovelace-picture-elements-card")
class DemoPictureElements extends LitElement {
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureEntityCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -33,75 +35,73 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "State on",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
tap_action:
action: toggle
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
tap_action: { action: "toggle" },
},
},
{
heading: "State off",
config: `
- type: picture-entity
image: /images/bed.png
entity: light.bed_light
tap_action:
action: toggle
`,
config: {
type: "picture-entity",
image: "/images/bed.png",
entity: "light.bed_light",
tap_action: { action: "toggle" },
},
},
{
heading: "Entity unavailable",
config: `
- type: picture-entity
image: /images/living_room.png
entity: light.non_existing
`,
config: {
type: "picture-entity",
image: "/images/living_room.png",
entity: "light.non_existing",
},
},
{
heading: "Camera entity",
config: `
- type: picture-entity
entity: camera.demo_camera
`,
config: {
type: "picture-entity",
entity: "camera.demo_camera",
},
},
{
heading: "Person entity",
config: `
- type: picture-entity
entity: person.paulus
`,
config: {
type: "picture-entity",
entity: "person.paulus",
},
},
{
heading: "Hidden name",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_name: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_name: false,
},
},
{
heading: "Hidden state",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_state: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_state: false,
},
},
{
heading: "Both hidden",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_name: false
show_state: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_name: false,
show_state: false,
},
},
];
] satisfies DemoCardConfig<PictureEntityCardConfig>[];
@customElement("demo-lovelace-picture-entity-card")
class DemoPictureEntity extends LitElement {
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureGlanceCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -58,110 +60,110 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Title, dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- switch.decorative_lights
- light.ceiling_lights
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
"switch.decorative_lights",
"light.ceiling_lights",
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Title, dialog, no toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Title, no dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- switch.decorative_lights
- light.ceiling_lights
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: ["switch.decorative_lights", "light.ceiling_lights"],
},
},
{
heading: "No title, dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- switch.decorative_lights
- light.ceiling_lights
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: [
"switch.decorative_lights",
"light.ceiling_lights",
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "No title, dialog, no toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: [
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "No title, no dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- switch.decorative_lights
- light.ceiling_lights
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: ["switch.decorative_lights", "light.ceiling_lights"],
},
},
{
heading: "Person entity",
config: `
- type: picture-glance
image_entity: person.paulus
entities:
- sensor.battery
`,
config: {
type: "picture-glance",
image_entity: "person.paulus",
entities: ["sensor.battery"],
},
},
{
heading: "Custom icon",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- entity: switch.decorative_lights
icon: mdi:power
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
{ entity: "switch.decorative_lights", icon: "mdi:power" },
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Custom tap action",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entity: light.ceiling_lights
tap_action:
action: toggle
entities:
- entity: switch.decorative_lights
icon: mdi:power
tap_action:
action: toggle
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entity: "light.ceiling_lights",
tap_action: { action: "toggle" },
entities: [
{
entity: "switch.decorative_lights",
icon: "mdi:power",
tap_action: { action: "toggle" },
},
"binary_sensor.basement_floor_wet",
],
},
},
];
] satisfies DemoCardConfig<PictureGlanceCardConfig>[];
@customElement("demo-lovelace-picture-glance-card")
class DemoPictureGlance extends LitElement {
+16 -14
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { PlantStatusCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { createPlantEntities } from "../../data/plants";
@@ -9,27 +11,27 @@ import { mockIcons } from "../../../../demo/src/stubs/icons";
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: plant-status
entity: plant.lemon_tree
`,
config: {
type: "plant-status",
entity: "plant.lemon_tree",
},
},
{
heading: "Problem (too bright) + low battery",
config: `
- type: plant-status
entity: plant.apple_tree
`,
config: {
type: "plant-status",
entity: "plant.apple_tree",
},
},
{
heading: "With picture + multiple problems",
config: `
- type: plant-status
entity: plant.sunflowers
name: Sunflowers Name Overwrite
`,
config: {
type: "plant-status",
entity: "plant.sunflowers",
name: "Sunflowers Name Overwrite",
},
},
];
] satisfies DemoCardConfig<PlantStatusCardConfig>[];
@customElement("demo-lovelace-plant-card")
export class DemoPlantEntity extends LitElement {
+108 -95
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ThermostatCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -123,120 +125,131 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Range example",
config: `
- type: thermostat
entity: climate.ecobee
`,
config: {
type: "thermostat",
entity: "climate.ecobee",
},
},
{
heading: "Single temp example",
config: `
- type: thermostat
entity: climate.nest
`,
config: {
type: "thermostat",
entity: "climate.nest",
},
},
{
heading: "Feature example",
config: `
- type: thermostat
entity: climate.overkiz_radiator
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- auto
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
- type: climate-preset-modes
style: dropdown
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
`,
config: {
type: "thermostat",
entity: "climate.overkiz_radiator",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["heat", "off", "auto"],
},
{
type: "climate-preset-modes",
style: "icons",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
},
{
type: "climate-preset-modes",
style: "dropdown",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
},
],
},
},
{
heading: "Preset only example",
config: `
- type: thermostat
entity: climate.overkiz_towel_dryer
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
`,
config: {
type: "thermostat",
entity: "climate.overkiz_towel_dryer",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["heat", "off"],
},
{
type: "climate-preset-modes",
style: "icons",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
},
],
},
},
{
heading: "Fan only example",
config: `
- type: thermostat
entity: climate.sensibo
features:
- type: climate-hvac-modes
hvac_modes:
- fan_only
- 'off'
- type: climate-fan-modes
style: icons
fan_modes:
- low
- high
- type: climate-swing-modes
style: icons
swing_modes:
- 'both'
- 'rangefull'
- 'off'
swing_horizontal_modes:
- 'both'
- 'rangefull'
- 'off'
`,
config: {
type: "thermostat",
entity: "climate.sensibo",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["fan_only", "off"],
},
{
type: "climate-fan-modes",
style: "icons",
fan_modes: ["low", "high"],
},
{
type: "climate-swing-modes",
style: "icons",
swing_modes: ["both", "rangefull", "off"],
},
{
type: "climate-swing-horizontal-modes",
style: "icons",
swing_horizontal_modes: ["both", "rangefull", "off"],
},
],
},
},
{
heading: "Unavailable",
config: `
- type: thermostat
entity: climate.unavailable
`,
config: {
type: "thermostat",
entity: "climate.unavailable",
},
},
{
heading: "Non existing",
config: `
- type: thermostat
entity: climate.nonexisting
`,
config: {
type: "thermostat",
entity: "climate.nonexisting",
},
},
];
] satisfies DemoCardConfig<ThermostatCardConfig>[];
@customElement("demo-lovelace-thermostat-card")
class DemoThermostatEntity extends LitElement {
+114 -123
View File
@@ -1,6 +1,7 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light";
import { LockEntityFeature } from "../../../../src/data/lock";
@@ -11,6 +12,7 @@ import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
const ENTITIES = [
{
@@ -166,190 +168,179 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: tile
entity: switch.tv_outlet
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
},
},
{
heading: "Vertical example",
config: `
- type: tile
entity: switch.tv_outlet
vertical: true
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
vertical: true,
},
},
{
heading: "Custom color",
config: `
- type: tile
entity: switch.tv_outlet
color: pink
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
color: "pink",
},
},
{
heading: "Whole tile tap action",
config: `
- type: tile
entity: switch.tv_outlet
color: pink
tap_action:
action: toggle
icon_tap_action:
action: none
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
color: "pink",
tap_action: {
action: "toggle",
},
icon_tap_action: {
action: "none",
},
},
},
{
heading: "Unknown entity",
config: `
- type: tile
entity: light.unknown
`,
config: {
type: "tile",
entity: "light.unknown",
},
},
{
heading: "Unavailable entity",
config: `
- type: tile
entity: light.unavailable
`,
config: {
type: "tile",
entity: "light.unavailable",
},
},
{
heading: "Climate",
config: `
- type: tile
entity: climate.thermostat
`,
config: {
type: "tile",
entity: "climate.thermostat",
},
},
{
heading: "Person",
config: `
- type: tile
entity: person.paulus
`,
config: {
type: "tile",
entity: "person.paulus",
},
},
{
heading: "Light brightness feature",
config: `
- type: tile
entity: light.bed_light
features:
- type: "light-brightness"
`,
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-brightness" }],
},
},
{
heading: "Light color temperature feature",
config: `
- type: tile
entity: light.bed_light
features:
- type: "color-temp"
`,
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color-temp" }],
},
},
{
heading: "Lock commands feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-commands"
`,
config: {
type: "tile",
entity: "lock.front_door",
features: [{ type: "lock-commands" }],
},
},
{
heading: "Lock open door feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-open-door"
`,
config: {
type: "tile",
entity: "lock.front_door",
features: [{ type: "lock-open-door" }],
},
},
{
heading: "Media player volume slider feature",
config: `
- type: tile
entity: media_player.living_room
features:
- type: "media-player-volume-slider"
`,
config: {
type: "tile",
entity: "media_player.living_room",
features: [{ type: "media-player-volume-slider" }],
},
},
{
heading: "Vacuum commands feature",
config: `
- type: tile
entity: vacuum.first_floor_vacuum
features:
- type: "vacuum-commands"
commands:
- start_pause
- stop
- return_home
`,
config: {
type: "tile",
entity: "vacuum.first_floor_vacuum",
features: [
{
type: "vacuum-commands",
commands: ["start_pause", "stop", "return_home"],
},
],
},
},
{
heading: "Cover open close feature",
config: `
- type: tile
entity: cover.kitchen_shutter
features:
- type: "cover-open-close"
`,
config: {
type: "tile",
entity: "cover.kitchen_shutter",
features: [{ type: "cover-open-close" }],
},
},
{
heading: "Cover tilt feature",
config: `
- type: tile
entity: cover.pergola_roof
features:
- type: "cover-tilt"
`,
config: {
type: "tile",
entity: "cover.pergola_roof",
features: [{ type: "cover-tilt" }],
},
},
{
heading: "Number buttons feature",
config: `
- type: tile
entity: input_number.counter
features:
- type: numeric-input
style: buttons
`,
config: {
type: "tile",
entity: "input_number.counter",
features: [{ type: "numeric-input", style: "buttons" }],
},
},
{
heading: "Dual thermostat feature",
config: `
- type: tile
entity: climate.dual_thermostat
features:
- type: target-temperature
`,
config: {
type: "tile",
entity: "climate.dual_thermostat",
features: [{ type: "target-temperature" }],
},
},
{
heading: "Fan direction feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-direction
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-direction" }],
},
},
{
heading: "Fan speed feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-speed
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan oscillate feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-oscillate
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-oscillate" }],
},
},
];
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
class DemoTile extends LitElement {
+12 -10
View File
@@ -4,6 +4,8 @@ import { customElement, query } from "lit/decorators";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { TodoListCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import "../../components/demo-cards";
const ENTITIES = [
@@ -27,20 +29,20 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "List example",
config: `
- type: todo-list
entity: todo.shopping_list
`,
config: {
type: "todo-list",
entity: "todo.shopping_list",
},
},
{
heading: "List with title example",
config: `
- type: todo-list
title: Shopping List
entity: todo.read_only
`,
config: {
type: "todo-list",
title: "Shopping List",
entity: "todo.read_only",
},
},
];
] satisfies DemoCardConfig<TodoListCardConfig>[];
@customElement("demo-lovelace-todo-list-card")
class DemoTodoListEntity extends LitElement {
+6 -5
View File
@@ -91,6 +91,7 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"chart2music": "1.20.0",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.49.0",
@@ -101,6 +102,7 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.6.16",
@@ -147,8 +149,8 @@
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
@@ -186,9 +188,8 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.8.0",
"globals": "17.9.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",
@@ -224,7 +225,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.8.0",
"globals": "17.9.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"
},
+2 -4
View File
@@ -1,9 +1,8 @@
import type { ReactiveElement } from "lit";
import { getHistoryState, updateHistoryState } from "../navigate";
import { throttle } from "../util/throttle";
const throttleReplaceState = throttle((value) => {
updateHistoryState({ scrollPosition: value });
history.replaceState({ scrollPosition: value }, "");
}, 300);
export function restoreScroll(selector: string) {
@@ -40,8 +39,7 @@ export function restoreScroll(selector: string) {
newDescriptor = {
get(this: ReactiveElement) {
return (
this[`__${String(propertyKey)}`] ||
getHistoryState()?.scrollPosition
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
);
},
set(this: ReactiveElement, value) {
+41 -78
View File
@@ -1,7 +1,6 @@
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
import { currentPath } from "./url/current-path";
declare global {
// for fire event
@@ -12,38 +11,12 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
data?: any;
}
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* State of the current history entry. Always read through this, the app writes
* to the main window and a panel running in an iframe has its own history.
*/
export const getHistoryState = (): any => mainWindow.history.state;
/**
* Merge into the current history entry's state, keeping what is already there.
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
* they must never be replaced wholesale.
*/
export const updateHistoryState = (patch: Record<string, unknown>) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, ...patch },
""
);
};
/**
* Rewrite the URL of the current history entry without navigating and without
* touching its state. For query parameter cleanup.
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
};
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
@@ -51,7 +24,10 @@ export const replaceCurrentUrl = (url: string) => {
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
updateHistoryState({ refreshUrl: path });
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
@@ -80,17 +56,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
) => {
const state = typeof data === "object" ? data : undefined;
if (from === undefined) {
return state ?? null;
}
return { ...state, from };
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
@@ -98,32 +63,37 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
}
const replace = options?.replace || false;
if (__DEMO__ && !path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
const { history } = mainWindow;
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
history.replaceState(buildHistoryState(data, from), "", path);
} else {
history.pushState(
buildHistoryState(options?.data, currentPath()),
if (__DEMO__) {
if (!path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -131,17 +101,8 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
* `location.assign`, which leaves /auth/authorize right behind the requested
* page, and going back there would bounce the user out of the app.
*/
export const canGoBack = (): boolean =>
mainWindow.history.state?.from !== undefined;
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -149,12 +110,14 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
mainWindow.history.back();
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
return;
}
await navigate(fallbackPath || "/", { replace: true });
// No history available, navigate to fallback path
const fallback = fallbackPath || "/";
navigate(fallback, { replace: true });
};
+13
View File
@@ -0,0 +1,13 @@
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
// only between the first and last character. The hyphen is escaped because a
// `pattern` attribute is compiled with the `v` flag, under which a trailing
// unescaped hyphen is an invalid character class — and a pattern that fails to
// compile is silently ignored, disabling validation altogether.
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
// hostname. Deliberately excludes underscores and a trailing dot.
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
-10
View File
@@ -1,10 +0,0 @@
import { mainWindow } from "../dom/get_main_window";
/**
* The path of the page currently shown by the app. The demo routes with the
* hash instead of the pathname, see navigate().
*/
export const currentPath = (): string =>
__DEMO__
? mainWindow.location.hash.substring(1)
: mainWindow.location.pathname;
+231
View File
@@ -0,0 +1,231 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { EChartsType } from "echarts/core";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { ensureArray } from "../../common/array/ensure-array";
import { formatDateTime } from "../../common/datetime/format_date_time";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HaECSeries,
HaECSeriesItem,
} from "../../resources/echarts/echarts";
export interface ChartSonification {
update: () => void;
dispose: () => void;
}
// Series types the Chart2Music ECharts extension can turn into data points. Our
// other series (custom timelines, sankey, network graphs) have no equivalent, and
// the extension refuses the whole chart if a single series is unsupported.
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
// Languages shipped by chart2music. Anything else falls back to its English.
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// Mirrors the extension's own reading of a point: it drops anything whose y is
// not a real number, so a series of nothing but nulls converts to an empty group
// and makes Chart2Music throw. Short-circuits on the first usable point.
const hasNumericPoint = (data: unknown): boolean =>
Array.isArray(data) &&
data.some((raw) => {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
}
return typeof y === "number" && !Number.isNaN(y);
});
export const canSonifyChart = (data: HaECSeries): boolean => {
const series = ensureArray(data);
return (
// Cards commonly push empty placeholder series, so only require that
// something is plottable — but every type has to be convertible.
series.some((s) => hasNumericPoint(s.data)) &&
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
);
};
interface SonifyChartOptions {
cc: HTMLElement;
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
onError: (error: string) => void;
}
// Chart2Music appends its help and options dialogs straight to document.body, so
// they can only be themed from a document-level stylesheet.
let stylesAppended = false;
const appendSonificationStyles = () => {
if (stylesAppended) {
return;
}
stylesAppended = true;
const style = document.createElement("style");
style.textContent = `
dialog.chart2music-dialog {
box-sizing: border-box;
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: var(--ha-space-6);
border: none;
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
box-shadow: var(--ha-box-shadow-l);
}
dialog.chart2music-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog.chart2music-dialog h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin-block: 0 var(--ha-space-4);
padding-inline-end: var(--ha-space-8);
}
dialog.chart2music-dialog table {
border-collapse: collapse;
width: 100%;
}
dialog.chart2music-dialog th,
dialog.chart2music-dialog td {
text-align: start;
padding: var(--ha-space-1) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
dialog.chart2music-dialog a {
color: var(--primary-color);
}
dialog.chart2music-dialog > button {
/* The extension inlines "right", which does not mirror in RTL, and inline
styles can only be beaten with !important. */
inset-inline-end: var(--ha-space-4) !important;
inset-inline-start: auto !important;
top: var(--ha-space-4);
min-width: 32px;
min-height: 32px;
cursor: pointer;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
background-color: transparent;
color: var(--primary-text-color);
font: inherit;
}
`;
document.head.append(style);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
): Promise<ChartSonification | null> => {
const { connect } = await import("echarts-extension-chart2music");
const { localize, locale, config } = options;
appendSonificationStyles();
// ECharts nulls its model on dispose, and the instance can be disposed while
// the chunk is in flight.
const chartOptions = chart.getOption() as ReturnType<
EChartsType["getOption"]
> | null;
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
// the series carrying at least one point it can read.
const seriesIndex: number[] = [];
ensureArray(chartOptions.series).forEach((s, index) => {
if (hasNumericPoint((s as HaECSeriesItem | undefined)?.data)) {
seriesIndex.push(index);
}
});
if (!seriesIndex.length) {
return null;
}
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
const isTimeAxis = xAxis?.type === "time";
const x = {
label:
xAxis?.name ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
? (value: number) => formatDateTime(new Date(value), locale, config)
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
try {
connection = connect(chart, {
cc: options.cc,
seriesIndex,
title: localize("ui.components.history_charts.chart"),
lang: SONIFICATION_LANGUAGES.has(locale.language)
? locale.language
: "en",
errorCallback: options.onError,
axes: { x, y },
});
} catch (err: any) {
options.onError(err?.message ?? String(err));
return null;
}
if (!connection) {
return null;
}
// Chart2Music bails out silently on mobile user agents, returning an instance
// that never wired anything up. Turning the caption container into a live
// region is the last thing it does, so use that as the "really connected" test
// rather than leaving a focus stop that does nothing.
if (!options.cc.hasAttribute("aria-live")) {
connection.dispose();
return null;
}
const connected = connection;
// The extension re-reads the chart from ECharts' own "finished" event. Run that
// through a guard of our own so a conversion failure cannot escape into
// ECharts' event dispatch and leave the chart half-rendered.
const update = () => {
try {
connected.update();
} catch (_err) {
// Keep whatever Chart2Music last read successfully.
}
};
chart.off("finished", connected.update);
chart.on("finished", update);
return {
update,
dispose: () => {
chart.off("finished", update);
connected.dispose();
},
};
};
+118 -1
View File
@@ -22,6 +22,7 @@ import type { PropertyValues } 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 { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -146,6 +149,17 @@ export class HaChartBase extends LitElement {
@query(".chart") private _chartContainer?: HTMLDivElement;
@query(".sonification-output")
private _sonificationOutput?: HTMLDivElement;
private _sonification?: ChartSonification;
private _sonificationLoading = false;
@state() private _sonificationUnavailable = false;
@state() private _sonificationFocusHeld = false;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -198,6 +212,7 @@ export class HaChartBase extends LitElement {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._disposeSonification();
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
@@ -312,6 +327,17 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
// last set was not.
this._sonificationUnavailable = false;
// The connection is built from the series that had data at the time, so
// drop it and let the next focus rebuild it against the current set.
if (
this._sonification &&
(changedProps.has("_hiddenDatasets") || !canSonifyChart(this.data))
) {
this._disposeSonification();
}
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -337,6 +363,8 @@ export class HaChartBase extends LitElement {
}
protected render() {
const sonifiable =
!this._sonificationUnavailable && canSonifyChart(this.data);
return html`
<div
class="container ${classMap({ "has-height": !!this.height })}"
@@ -348,8 +376,22 @@ export class HaChartBase extends LitElement {
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
<div
class="chart"
role=${ifDefined(sonifiable ? "application" : undefined)}
tabindex=${ifDefined(
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
)}
aria-label=${ifDefined(
sonifiable
? this.hass.localize("ui.components.history_charts.chart")
: undefined
)}
@focus=${this._handleChartFocus}
@blur=${this._handleChartBlur}
></div>
</div>
<div class="sonification-output"></div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
@@ -521,6 +563,61 @@ export class HaChartBase extends LitElement {
</div>`;
}
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
// moves keyboard focus into a chart.
private async _handleChartFocus() {
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
try {
const sonification = await sonifyChart(this.chart, {
cc: this._sonificationOutput!,
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
},
});
if (!this.isConnected || !this.chart) {
sonification?.dispose();
return;
}
if (!sonification) {
// Nothing came back, so stop offering a focus stop that leads nowhere.
// Stay programmatically focusable for as long as we hold focus though:
// dropping tabindex off the active element resets focus to the document
// and costs the user their place in the tab order.
this._sonificationFocusHeld =
this.shadowRoot?.activeElement === this._chartContainer;
this._sonificationUnavailable = true;
return;
}
this._sonification = sonification;
if (this.shadowRoot?.activeElement === this._chartContainer) {
// Chart2Music reads its summary and key hints on focus, which already
// happened while it was still being fetched.
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
}
} catch (_err) {
// Never let a failure here escape a focus handler. The tab stop stays, so
// focusing the chart again retries.
} finally {
this._sonificationLoading = false;
}
}
private _handleChartBlur() {
this._sonificationFocusHeld = false;
}
private _disposeSonification() {
this._sonification?.dispose();
this._sonification = undefined;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
@@ -533,6 +630,9 @@ export class HaChartBase extends LitElement {
if (this._loading) return;
this._loading = true;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
}
@@ -1450,6 +1550,23 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
/* Chart2Music renders its announcements here. It must stay in the layout for
screen readers to pick up the live region, so hide it visually only. */
.sonification-output {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
+5 -6
View File
@@ -53,8 +53,10 @@ export class HaControlNumberButton extends LitElement {
});
private _boundedValue(value: number) {
const clamped = conditionalClamp(value, this.min, this.max);
return Math.round(clamped / this._step) * this._step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
const stepped = Math.round(value / this._step) * this._step;
return conditionalClamp(stepped, this.min, this.max);
}
private get _step() {
@@ -67,10 +69,7 @@ export class HaControlNumberButton extends LitElement {
private get _tenPercentStep() {
if (this.max == null || this.min == null) return this._step;
const range = this.max - this.min / 10;
if (range <= this._step) return this._step;
return Math.max(range / 10);
return Math.max((this.max - this.min) / 10, this._step);
}
private _handlePlusButton() {
+5 -7
View File
@@ -115,7 +115,9 @@ export class HaControlSlider extends LitElement {
}
steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
return this.boundedValue(Math.round(value / this.step) * this.step);
}
private _displayedValue(value: number) {
@@ -254,13 +256,9 @@ export class HaControlSlider extends LitElement {
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
} else if (e.code === "PageDown") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
} else {
const isRtl = mainWindow.document.dir === "rtl";
let multiplier = 1;
+14 -1
View File
@@ -18,6 +18,8 @@ type HlsLite = Omit<
"subtitleTrackController" | "audioTrackController" | "emeController"
>;
const HIDDEN_CLEANUP_DELAY = 60000;
@customElement("ha-hls-player")
class HaHLSPlayer extends LitElement {
@state()
@@ -76,13 +78,22 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0;
private _hiddenCleanupTimeout?: number;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._cleanUp();
this._hiddenCleanupTimeout = window.setTimeout(() => {
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
}, HIDDEN_CLEANUP_DELAY);
} else if (this._hiddenCleanupTimeout) {
// stream was not cleaned up yet, just cancel the cleanup
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._resetError();
this._startHls();
@@ -105,6 +116,8 @@ class HaHLSPlayer extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
HaHLSPlayer.streamCount -= 1;
this._cleanUp();
}
@@ -131,6 +131,7 @@ export class HaNumberSelector extends LitElement {
.hint=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.validationMessage=${this.selector.number?.validation_message}
type="number"
autoValidate
.withoutSpinButtons=${!isBox}
+27 -3
View File
@@ -31,6 +31,8 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -148,7 +150,12 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
return;
}
@@ -156,7 +163,12 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
return;
}
@@ -187,12 +199,15 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -253,6 +268,15 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
+14 -1
View File
@@ -17,6 +17,8 @@ import {
import { apiContext, connectionContext } from "../data/context";
import "./ha-alert";
const HIDDEN_CLEANUP_DELAY = 60000;
/**
* A WebRTC stream is established by first sending an offer through a signal
* path via an integration. An answer is returned, then the rest of the stream
@@ -68,13 +70,22 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = [];
private _hiddenCleanupTimeout?: number;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._cleanUp();
this._hiddenCleanupTimeout = window.setTimeout(() => {
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
}, HIDDEN_CLEANUP_DELAY);
} else if (this._hiddenCleanupTimeout) {
// stream was not cleaned up yet, just cancel the cleanup
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._startWebRtc();
}
@@ -116,6 +127,8 @@ class HaWebRtcPlayer extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
this._cleanUp();
}
+69
View File
@@ -4,6 +4,7 @@ import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type {
Circle,
CircleMarker,
Control,
LatLngExpression,
LatLngTuple,
Layer,
@@ -48,6 +49,7 @@ import type {
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { UNIT_KM } from "../../common/const";
declare global {
// for fire event
@@ -147,6 +149,9 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "cluster-markers", type: Boolean })
public clusterMarkers = true;
@property({ attribute: "scale-ruler", type: Boolean })
public scaleRuler = false;
@state() private _loaded = false;
@query("#map") private _mapElement?: HTMLElement;
@@ -167,6 +172,8 @@ export class HaMap extends ReactiveElement {
private _mapCluster: MarkerClusterGroup | undefined;
private _scaleRulerControl?: Control.Scale;
private _mapPaths: (Polyline | CircleMarker)[] = [];
private _clickCount = 0;
@@ -206,6 +213,8 @@ export class HaMap extends ReactiveElement {
this.Leaflet = undefined;
}
// the control went away with the map, so don't hold on to it
this._scaleRulerControl = undefined;
this._pendingFit = undefined;
this._loaded = false;
@@ -243,6 +252,16 @@ export class HaMap extends ReactiveElement {
this._drawEntities();
}
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
if (
changedProps.has("_loaded") ||
changedProps.has("scaleRuler") ||
(changedProps.has("_config") &&
oldConfig?.unit_system?.length !== this._config?.unit_system?.length)
) {
this._drawScaleRuler();
}
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
}
@@ -806,6 +825,25 @@ export class HaMap extends ReactiveElement {
this._mapZones.forEach((marker) => map.addLayer(marker));
}
private _drawScaleRuler(): void {
if (this._scaleRulerControl) {
this.leafletMap?.removeControl(this._scaleRulerControl);
this._scaleRulerControl = undefined;
}
if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) {
return;
}
const metric = this._config?.unit_system?.length === UNIT_KM;
this._scaleRulerControl = this.Leaflet.control.scale({
position: "bottomleft",
metric,
imperial: !metric,
});
this._scaleRulerControl.addTo(this.leafletMap);
}
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
const markerSizeVarValue =
computedStyles.getPropertyValue("--ha-marker-size");
@@ -886,6 +924,37 @@ export class HaMap extends ReactiveElement {
.leaflet-bottom {
z-index: 1 !important;
}
.leaflet-control-scale {
cursor: unset !important;
}
.leaflet-control-scale-line {
--scale-ruler-color: var(--ha-color-on-surface-default);
--scale-ruler-surface: var(--ha-color-surface-default);
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-body);
color: var(--scale-ruler-color) !important;
background: color-mix(
in srgb,
var(--scale-ruler-surface) 80%,
transparent
) !important;
text-shadow: none !important;
}
/* the theme tokens follow the page, so forced modes need the opposite values */
#map.forced-light .leaflet-control-scale-line {
--scale-ruler-color: var(--ha-color-neutral-05);
--scale-ruler-surface: var(--ha-color-white);
}
#map.forced-dark .leaflet-control-scale-line {
--scale-ruler-color: var(--ha-color-neutral-95);
--scale-ruler-surface: var(--ha-color-neutral-10);
}
.leaflet-left .leaflet-control-scale {
margin-left: 10px !important;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 10px !important;
}
.leaflet-tooltip {
padding: 8px;
font-size: var(--ha-font-size-s);
+3
View File
@@ -397,6 +397,9 @@ export interface NumberSelector {
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
// Shown instead of the browser's native message when the value fails
// min/max/step constraint validation.
validation_message?: string;
} | null;
}
+10 -10
View File
@@ -40,11 +40,7 @@ import {
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../common/navigate";
import { navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -272,12 +268,16 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
view,
history.replaceState(
{
...history.state,
dialogParams: {
...history.state?.dialogParams,
view,
},
},
});
""
);
this._currView = view;
}
-30
View File
@@ -1,30 +0,0 @@
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
/**
* Shared behavior of the toolbar back arrow. The arrow is a link to the
* declared parent page so it can be opened in a new tab, but a plain click
* returns to the page the user came from instead.
*/
export const handleBackClick = (
ev: MouseEvent,
backPath?: string,
backCallback?: () => void
): void => {
const path = sanitizeNavigationPath(backPath);
// Ctrl, cmd and shift click open the parent in a new tab or window: let
// the anchor handle those. A plain click is handled here instead, and
// isNavigationClick calls preventDefault so the anchor stays inert.
if (path && !isNavigationClick(ev)) {
return;
}
if (backCallback) {
backCallback();
return;
}
goBack(path);
};
+2 -2
View File
@@ -1,7 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { getHistoryState, goBack } from "../common/navigate";
import { goBack } from "../common/navigate";
import "../components/ha-button";
import "../components/ha-top-app-bar-fixed";
import type { HomeAssistant } from "../types";
@@ -27,7 +27,7 @@ class HassErrorScreen extends LitElement {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || getHistoryState()?.root)}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
+1 -2
View File
@@ -1,7 +1,6 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { getHistoryState } from "../common/navigate";
import "../components/animation/ha-fade-in";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
@@ -28,7 +27,7 @@ class HassLoadingScreen extends LitElement {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || getHistoryState()?.root)}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
+19 -11
View File
@@ -4,9 +4,8 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { getHistoryState } from "../common/navigate";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -38,14 +37,19 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
this.mainPage || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: backPath
? html`
<ha-icon-button-arrow-prev
href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
@@ -75,8 +79,12 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
}
static get styles(): CSSResultGroup {
+19 -11
View File
@@ -14,10 +14,9 @@ import { canShowPage } from "../common/config/can_show_page";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -174,14 +173,19 @@ export class HassTabsSubpage extends LitElement {
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!backPath && getHistoryState()?.root)
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: backPath
? html`
<ha-icon-button-arrow-prev
.href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -242,8 +246,12 @@ export class HassTabsSubpage extends LitElement {
this._content.focus({ preventScroll: true });
}
private _backTapped(ev: MouseEvent): void {
handleBackClick(ev, this.backPath, this.backCallback);
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
+220 -81
View File
@@ -1,7 +1,20 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -10,7 +23,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
// Unique ID for updating or closing a specific toast without flickering.
id?: string;
message:
| string
@@ -34,105 +47,150 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _parameters?: ShowToastParams;
@state() private _notifications: Notification[] = [];
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@query(".stack") private _stack?: HTMLDivElement;
private _showDialogId = 0;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
public async showDialog(parameters: ShowToastParams) {
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
if (parameters.duration === 0) {
this._parameters = undefined;
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
return;
}
this._parameters = parameters;
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
await this.updateComplete;
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
this._showStack();
this._getToast(key)?.show();
}
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
if (ev.detail.reason === "dismiss") {
this._parameters?.dismiss?.();
this._getNotification(key)?.parameters.dismiss?.();
}
this._parameters = undefined;
this._removeNotification(key);
}
protected render() {
if (!this._parameters) {
if (!this._notifications.length) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -141,6 +199,7 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -155,19 +214,99 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
}
private _dismissClicked() {
this._toast?.hide("dismiss");
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || !this._stack) {
return;
}
// Top-layer order is order of entry — re-enter so we paint above any
// dialog backdrop opened since the stack was first shown.
if (this._stack.matches(":popover-open")) {
this._stack.hidePopover();
}
this._stack.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
@@ -455,7 +456,7 @@ class DialogAreaDetail
return deviceReg && deviceReg.area_id === areaId;
};
private _nameChanged(ev: InputEvent) {
private _nameChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
@@ -479,7 +480,9 @@ class DialogAreaDetail
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
private _pictureChanged(
ev: ValueChangedEvent<string | null> & HASSDomTargetEvent<HaPictureUpload>
) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
@@ -490,7 +493,9 @@ class DialogAreaDetail
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
private _sensorChanged(
ev: ValueChangedEvent<string> & HASSDomTargetEvent<HaEntityPicker>
): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-alert";
@@ -336,13 +337,13 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
this._updateDirtyState(this._currentState());
}
private _nameChanged(ev: InputEvent) {
private _nameChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _levelChanged(ev: InputEvent) {
private _levelChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
this._error = undefined;
this._level =
(ev.target as HaInput).value === ""
@@ -644,7 +644,6 @@ class HaConfigAreaPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/areas/dashboard"
.header=${html`${
area.icon
? html`<ha-icon
@@ -86,6 +86,8 @@ export class HaConfigAreasDashboard extends LitElement {
@state() private _hierarchy?: AreasFloorHierarchy;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
@@ -166,7 +168,9 @@ export class HaConfigAreasDashboard extends LitElement {
<hass-tabs-subpage
.hass=${this.hass}
.isWide=${this.isWide}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.tabs=${configSections.areas}
.route=${this.route}
has-fab
@@ -49,10 +49,7 @@ export default class HaAutomationActionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
!this.indent &&
(this.disabled ||
(this.action.enabled === false && !this.yamlMode)),
disabled: !this.indent && this.disabled,
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -56,10 +56,7 @@ export default class HaAutomationConditionEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
!this.indent &&
(this.disabled ||
(this.condition.enabled === false && !this.yamlMode)),
disabled: !this.indent && this.disabled,
yaml: yamlMode,
indent: this.indent,
card: !this.inSidebar,
@@ -986,7 +986,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _delete() {
if (this.automationId) {
await deleteAutomation(this.hass, this.automationId);
goBack(this.dashboardPath);
goBack("/config");
}
}
@@ -443,7 +443,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
@@ -147,10 +147,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected domainHooks!: EditorDomainHooks<TConfig>;
protected get dashboardPath(): string {
return `/config/${this.domainHooks.domain}/dashboard`;
}
protected entityRegCreated?: (
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void;
@@ -256,7 +252,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected backTapped = async () => {
const result = await this.confirmUnsavedChanged();
if (result) {
afterNextRender(() => goBack(this.dashboardPath));
afterNextRender(() => goBack("/config"));
}
};
@@ -304,7 +300,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
),
text: html`<pre>${alertText}</pre>`,
});
goBack(this.dashboardPath);
goBack("/config");
return;
}
const entity = this.entityRegistry?.find(
@@ -321,7 +317,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
`ui.panel.config.${domain}.editor.load_error_not_editable`
),
});
goBack(this.dashboardPath);
goBack("/config");
}
}
}
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
@@ -110,7 +110,6 @@ export class HaAutomationTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/automation/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -453,7 +452,11 @@ export class HaAutomationTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
}
await showAlertDialog(this, {
@@ -11,7 +11,6 @@ import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
@@ -36,6 +35,8 @@ import type HaAutomationSidebar from "./ha-automation-sidebar";
export const SIDEBAR_DEFAULT_WIDTH = 500;
export const PASTED_CONFIG_TOAST_ID = "pasted-config";
export const ManualEditorMixin = <TConfig>(
superClass: Constructor<LitElement>
) => {
@@ -172,7 +173,11 @@ export const ManualEditorMixin = <TConfig>(
}
protected clearParam(param: string) {
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
@@ -234,6 +239,7 @@ export const ManualEditorMixin = <TConfig>(
this.pastedConfig = undefined;
showToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: "",
duration: 0,
});
@@ -37,7 +37,10 @@ import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "./ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
@@ -431,6 +434,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_toast_message"
),
@@ -48,11 +48,7 @@ export default class HaAutomationTriggerEditor extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled:
this.disabled ||
("enabled" in this.trigger &&
this.trigger.enabled === false &&
!this.yamlMode),
disabled: this.disabled,
yaml: yamlMode,
card: !this.inSidebar,
})}
@@ -74,6 +74,8 @@ class HaConfigBackupOverview extends LitElement {
@state() private _config?: BackupConfig;
private _searchParms = new URLSearchParams(window.location.search);
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
@@ -204,7 +206,9 @@ class HaConfigBackupOverview extends LitElement {
return html`
<hass-subpage
back-path="/config/system"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-alert";
@@ -119,7 +118,7 @@ class HaConfigBackupSettings extends LitElement {
}
private _clearHash() {
replaceCurrentUrl(window.location.pathname);
history.replaceState(null, "", window.location.pathname);
}
protected render() {
@@ -2,6 +2,7 @@ import { mdiClose, mdiOpenInNew } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { withViewTransition } from "../../../common/util/view-transition";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -279,7 +280,7 @@ class DialogImportBlueprint extends DirtyStateProviderMixin<BlueprintImportState
});
}
private _inputChanged(ev: Event) {
private _inputChanged(ev: HASSDomTargetEvent<HaInput>) {
this._updateDirtyState({
value: (ev.target as HaInput).value ?? "",
hasResult: !!this._result,
@@ -13,7 +13,10 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate";
@@ -483,7 +486,7 @@ class HaBlueprintOverview extends LitElement {
this._createNew(blueprint);
}
private _handleUsageClick = (ev: Event) => {
private _handleUsageClick = (ev: HASSDomCurrentTargetEvent<HTMLElement>) => {
ev.stopPropagation();
ev.preventDefault();
const target = ev.currentTarget as HTMLElement | null;
@@ -21,7 +21,6 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
@@ -45,7 +45,6 @@ export class CloudLoginPanel extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
@@ -38,7 +38,6 @@ export class CloudRegister extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<div class="content">
@@ -66,6 +66,8 @@ class HaConfigSectionUpdates extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showSkipped = false;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@@ -153,7 +155,9 @@ class HaConfigSectionUpdates extends LitElement {
return html`
<hass-subpage
back-path="/config/system"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
@@ -986,7 +986,6 @@ export class HaConfigDevicePage extends LitElement {
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/devices/dashboard"
.header=${deviceName}
>
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
@@ -23,11 +23,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
@@ -146,7 +142,7 @@ export class HaConfigDeviceDashboard extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
private _filter: string = history.state?.filter || "";
@state()
private _filters: DataTableFilters = {};
@@ -266,7 +262,7 @@ export class HaConfigDeviceDashboard extends LitElement {
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-states": {
@@ -782,7 +778,9 @@ export class HaConfigDeviceDashboard extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(
@@ -1045,7 +1043,7 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
history.replaceState({ filter: this._filter }, "");
}
private _addDevice() {
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
@@ -340,7 +341,7 @@ export class DialogEnergyBatterySettings
}
private _handlePowerConfigChanged(
ev: CustomEvent<{ powerType: PowerType; powerConfig: PowerConfig }>
ev: HASSDomEvent<HASSDomEvents["power-config-changed"]>
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
@@ -214,7 +215,7 @@ export class DialogEnergyCustomise
`;
}
private _toggleCard = (ev: Event): void => {
private _toggleCard = (ev: HASSDomCurrentTargetEvent<HaSwitch>): void => {
const target = ev.currentTarget as HaSwitch;
const cardKey = target.dataset.cardKey;
if (!cardKey) {
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
@@ -353,7 +354,7 @@ export class DialogEnergyGasSettings
`;
}
private _handleCostChanged(ev: Event) {
private _handleCostChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
this._updateFormDirtyState();
}
@@ -2,6 +2,10 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
@@ -593,7 +597,9 @@ export class DialogEnergyGridSettings
this._updateFormDirtyState();
}
private _handleImportCostTypeChanged(ev: Event) {
private _handleImportCostTypeChanged(
ev: HASSDomCurrentTargetEvent<HaRadioGroup>
) {
this._importCostType = (ev.currentTarget as HaRadioGroup).value as CostType;
// Clear other cost fields when switching types
this._source = {
@@ -605,7 +611,9 @@ export class DialogEnergyGridSettings
this._updateFormDirtyState();
}
private _handleExportCostTypeChanged(ev: Event) {
private _handleExportCostTypeChanged(
ev: HASSDomCurrentTargetEvent<HaRadioGroup>
) {
this._exportCostType = (ev.currentTarget as HaRadioGroup).value as CostType;
// Clear other cost fields when switching types
this._source = {
@@ -630,9 +638,10 @@ export class DialogEnergyGridSettings
this._updateFormDirtyState();
}
private _numberCostChanged(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const value = input.value ? parseFloat(input.value) : null;
private _numberCostChanged(ev: HASSDomCurrentTargetEvent<HaInput>) {
const value = ev.currentTarget.value
? parseFloat(ev.currentTarget.value)
: null;
this._source = { ...this._source!, number_energy_price: value };
this._updateFormDirtyState();
}
@@ -653,15 +662,16 @@ export class DialogEnergyGridSettings
this._updateFormDirtyState();
}
private _numberCompensationChanged(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const value = input.value ? parseFloat(input.value) : null;
private _numberCompensationChanged(ev: HASSDomCurrentTargetEvent<HaInput>) {
const value = ev.currentTarget.value
? parseFloat(ev.currentTarget.value)
: null;
this._source = { ...this._source!, number_energy_price_export: value };
this._updateFormDirtyState();
}
private _handlePowerConfigChanged(
ev: CustomEvent<{ powerType: PowerType; powerConfig: PowerConfig }>
ev: HASSDomEvent<HASSDomEvents["power-config-changed"]>
) {
this._powerType = ev.detail.powerType;
this._powerConfig = ev.detail.powerConfig;
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-checkbox";
@@ -12,6 +13,7 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-svg-icon";
import "../../../../components/radio/ha-radio-group";
import "../../../../components/input/ha-input";
import { domainToName } from "../../../../data/integration";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { ConfigEntry } from "../../../../data/config_entries";
@@ -234,7 +236,7 @@ export class DialogEnergySolarSettings
},
this.hass.auth.data.hassUrl
)}
/>${entry.title}
/>${entry.title || domainToName(this.hass.localize, entry.domain)}
</div>
</ha-checkbox>`
)}
@@ -290,7 +292,7 @@ export class DialogEnergySolarSettings
);
}
private _handleForecastChanged(ev: Event) {
private _handleForecastChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
this._forecast = (ev.currentTarget as HaRadioGroup).value === "true";
this._updateFormDirtyState();
}
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
@@ -289,7 +290,7 @@ export class DialogEnergyWaterSettings
`;
}
private _handleCostChanged(ev: Event) {
private _handleCostChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
this._updateFormDirtyState();
}
@@ -3,6 +3,7 @@ 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 type { HASSDomCurrentTargetEvent } 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";
@@ -234,7 +235,7 @@ export class HaEnergyPowerConfig extends LitElement {
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
}
private _handlePowerTypeChanged(ev: Event) {
private _handlePowerTypeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
// Clear power config when switching types
fireEvent(this, "power-config-changed", {
+7 -1
View File
@@ -78,6 +78,8 @@ class HaConfigEnergy extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _info?: EnergyInfo;
@state() private _preferences?: EnergyPreferences;
@@ -124,7 +126,11 @@ class HaConfigEnergy extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
back-path="/config/lovelace/dashboards"
.backPath=${
this._searchParms.has("historyBack")
? undefined
: "/config/lovelace/dashboards"
}
.route=${this.route}
.tabs=${TABS}
>
@@ -39,7 +39,6 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
@@ -193,7 +192,7 @@ export class HaConfigEntities extends LitElement {
state: true,
subscribe: false,
})
private _filter: string = getHistoryState()?.filter || "";
private _filter: string = history.state?.filter || "";
@state() private _searchParms = new URLSearchParams(window.location.search);
@@ -811,7 +810,9 @@ export class HaConfigEntities extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize, filteredEntities)}
@@ -1110,7 +1111,7 @@ export class HaConfigEntities extends LitElement {
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-states": [],
@@ -1245,7 +1246,7 @@ export class HaConfigEntities extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
updateHistoryState({ filter: this._filter });
history.replaceState({ filter: this._filter }, "");
}
private _handleSelectionChanged(
+1
View File
@@ -106,6 +106,7 @@ class HaPanelConfig extends HassRouterPage {
integrations: {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),
waitForReady: true,
},
labels: {
tag: "ha-config-labels",
@@ -24,7 +24,7 @@ import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { getHistoryState, navigate } from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import type {
LocalizeFunc,
LocalizeKeys,
@@ -644,7 +644,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
@@ -1007,7 +1009,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
}
this._fromUrl = true;
this._filter = getHistoryState()?.filter || "";
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
@@ -373,11 +373,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
: this._manifest?.documentation;
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/integrations/dashboard"
>
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
${
documentationLink
? html`
@@ -1,4 +1,5 @@
import { mdiFilterVariant, mdiPlus } from "@mdi/js";
import { consume } from "@lit/context";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
@@ -13,11 +14,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
@@ -58,6 +55,10 @@ import type { ImprovDiscoveredDevice } from "../../../external_app/external_mess
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import type { HassTabsSubpage } from "../../../layouts/hass-tabs-subpage";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
@@ -162,7 +163,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
window.location.hash.substring(1)
);
@state() private _filter: string = getHistoryState()?.filter || "";
@state() private _searchParams = new URLSearchParams(window.location.search);
@state() private _filter: string = history.state?.filter || "";
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@@ -170,6 +173,17 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
@query("hass-tabs-subpage") private _tabsSubpage?: HassTabsSubpage;
private _resolveInitialRender?: () => void;
private _initialRenderComplete = new Promise<void>((resolve) => {
this._resolveInitialRender = resolve;
});
private _childReadyRegistered = false;
@consume({ context: childPanelReadyContext, subscribe: true })
private _registerChildPanelReady?: RegisterChildPanelReady;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(
@@ -390,6 +404,10 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
protected updated(changed: PropertyValues<this>) {
super.updated(changed);
if (!this._childReadyRegistered && this._registerChildPanelReady) {
this._registerChildPanelReady(this._initialRenderComplete);
this._childReadyRegistered = true;
}
if (changed.has("route")) {
this._handleRouteChanged();
}
@@ -417,6 +435,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
if (this.configEntries && this.configEntriesInProgress) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
const activeElement = deepActiveElement();
if (
@@ -505,7 +526,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
return html`
<hass-tabs-subpage
.hass=${this.hass}
back-path="/config"
.backPath=${
this._searchParams.has("historyBack") ? undefined : "/config"
}
.route=${this.route}
.tabs=${configSections.devices}
has-fab
@@ -862,7 +885,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
updateHistoryState({ filter: this._filter });
history.replaceState({ filter: this._filter }, "");
}
private async _highlightEntry() {
@@ -11,6 +11,7 @@ import {
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import "../../../layouts/hass-loading-screen";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@@ -70,6 +71,11 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
private _loadTranslationsPromise?: Promise<LocalizeFunc>;
public constructor() {
super();
new ChildPanelReady(this);
}
public hassSubscribe() {
return [
subscribeConfigEntries(
@@ -3,6 +3,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
@@ -451,7 +452,9 @@ export class BluetoothAdapterInfoPage extends LitElement {
return this._formatModeLabel(scannerState.current_mode);
}
private async _handleEnable(ev: Event) {
private async _handleEnable(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entry: ConfigEntry }>
) {
const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry };
const entryId = button.entry.entry_id;
try {
@@ -474,7 +477,9 @@ export class BluetoothAdapterInfoPage extends LitElement {
}
}
private _openOptionFlow(ev: Event) {
private _openOptionFlow(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entry: ConfigEntry }>
) {
ev.preventDefault();
ev.stopPropagation();
const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry };

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