mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-07 15:06:57 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b6f4942f | ||
|
|
ea5a1bca99 | ||
|
|
7ae5dafc95 | ||
|
|
52cc65cbfc | ||
|
|
f36330274d | ||
|
|
a4912d0706 | ||
|
|
3900a804f0 | ||
|
|
cd5dfdb86f | ||
|
|
a602865117 | ||
|
|
f42a5012a9 | ||
|
|
90f5c7a349 | ||
|
|
d03ba15c09 | ||
|
|
7143acc860 | ||
|
|
c9c46d4507 | ||
|
|
5916775745 | ||
|
|
52ee78d8a2 | ||
|
|
146a089044 | ||
|
|
8c566b43b6 | ||
|
|
1089c5d1c5 | ||
|
|
2894113033 | ||
|
|
b9bbef3bfc | ||
|
|
706382cb68 | ||
|
|
cc17921c22 | ||
|
|
b5ef563b71 |
@@ -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;
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+7
-2
@@ -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
Reference in New Issue
Block a user