Compare commits

...

15 Commits

Author SHA1 Message Date
Petar Petrov f42a5012a9 Accept hostnames and localize validation messages in the HTTP config form (#53511)
Accept hostnames in HTTP config listen addresses and localize number validation
2026-08-05 12:52:52 +02:00
Petar Petrov 90f5c7a349 Only show the sidebar tab switcher when its tabs are labelled (#53508) 2026-08-05 12:31:15 +02:00
Aidan Timson d03ba15c09 Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-08-05 11:54:11 +03:00
Petar Petrov 7143acc860 Remove orphaned for_you translation key (#53509) 2026-08-05 08:30:11 +01:00
Aidan Timson c9c46d4507 Use typed gallery demo card configs (#53492) 2026-08-05 08:26:51 +01:00
Aidan Timson 5916775745 Wait for Integrations panel readiness (#53481)
* Wait for Integrations panel readiness

* Consolidate panel readiness tests

* Address panel readiness review feedback

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-05 08:44:16 +03:00
pcan08 52ee78d8a2 Add support for climate in target humidity feature (#53502)
* Add target humidity feature to climate entity

* Use target_humidity_step if any instead of fixed step

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

* Fix event handler element typings

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

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

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

* Correction

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Correction

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

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

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-08-05 08:23:14 +03:00
Paul Bottein 8c566b43b6 Compress faster by running brotli in parallel (#53495) 2026-08-05 08:19:30 +03:00
larry 1089c5d1c5 Fix missing Internet heading in Home Assistant URL settings (#53498)
fix(config): always show the Internet URL heading

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

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

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


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

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

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 08:02:58 +03:00
Petar Petrov b9bbef3bfc Add expand_legend option to energy graph cards (#53476) 2026-08-05 08:02:28 +03:00
Paul Bottein 706382cb68 Fix back navigation loop on the cloud page (#53497) 2026-08-05 06:50:34 +02:00
karwosts cc17921c22 Fix missing label in solar forecast dialog (#53506) 2026-08-05 06:49:42 +02:00
renovate[bot] b5ef563b71 Update octokit monorepo (#53505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 06:43:34 +02:00
99 changed files with 3375 additions and 1989 deletions
+46
View File
@@ -0,0 +1,46 @@
// Gulp transform that brotli-compresses files, several at a time.
//
// Drop-in replacement for gulp-brotli. zlib already does the work off the main
// thread, but that plugin wraps it in through2, which waits for each file
// before starting the next, so only one compression is ever in flight. The
// compressed bytes are unchanged; only how many run at once differs.
//
// The real ceiling is libuv's threadpool, which zlib runs on. It sizes itself
// from UV_THREADPOOL_SIZE before any JavaScript runs, so it can only be raised
// from the environment, never from inside the build.
import { availableParallelism } from "node:os";
import { buffer as readStream } from "node:stream/consumers";
import { promisify } from "node:util";
import { brotliCompress } from "node:zlib";
import { ParallelTransform } from "./parallel-transform.mjs";
const EXTENSION = ".br";
const compress = promisify(brotliCompress);
/**
* @param {object} [options]
* @param {boolean} [options.skipLarger] Drop files that compression grows.
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
*/
export default ({ skipLarger = false, params } = {}) =>
new ParallelTransform(availableParallelism(), async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
const compressed = await compress(file.contents, { params });
if (skipLarger && compressed.length >= file.contents.length) {
// Dropped rather than passed through, as gulp-brotli did: the
// uncompressed file is already in the output directory.
return undefined;
}
file.contents = compressed;
file.path += EXTENSION;
return file;
});
+1 -1
View File
@@ -2,7 +2,7 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import brotli from "../brotli.mjs";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
+64
View File
@@ -0,0 +1,64 @@
// Object-mode transform that keeps several files in flight at once.
//
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which serialises asynchronous work down to one file
// at a time. This keeps `limit` files in flight and applies backpressure beyond
// that. Files are emitted in completion order rather than input order.
import { Transform } from "node:stream";
export class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
+1 -60
View File
@@ -6,9 +6,9 @@
// instance per worker parallelises it; the output bytes are unchanged.
import { availableParallelism } from "node:os";
import { Transform } from "node:stream";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
import { ParallelTransform } from "./parallel-transform.mjs";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
@@ -112,65 +112,6 @@ const sharedPool = () => {
return pool;
};
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which would serialise the pool down to one worker.
// This keeps `limit` files in flight and applies backpressure beyond that.
class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
@@ -0,0 +1,7 @@
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { getCardElementClass } from "../../../src/panels/lovelace/create-element/create-card-element";
export const validateCardConfig = async (config: LovelaceCardConfig) => {
const cardClass = await getCardElementClass(config.type);
new cardClass().setConfig(config);
};
+40 -9
View File
@@ -1,15 +1,21 @@
import { load } from "js-yaml";
import { dump } from "js-yaml";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../src/components/ha-alert";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { validateCardConfig } from "../common/validate-card-config";
export interface DemoCardConfig {
export interface DemoCardConfig<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
heading: string;
config: string;
config: T;
expectConfigError?: boolean;
}
@customElement("demo-card")
@@ -23,12 +29,29 @@ class DemoCard extends LitElement {
@state() private _size?: number;
@state() private _configError?: string;
@query("hui-card", false) private _card?: HuiCard;
private _config = memoizeOne((config: string) => {
const c = (load(config) as any)[0];
return c;
});
private _yamlConfig = memoizeOne((config: LovelaceCardConfig) =>
dump([config]).trim()
);
protected async firstUpdated() {
try {
await validateCardConfig(this.config.config);
} catch (err) {
if (this.config.expectConfigError) {
return;
}
this._configError = err instanceof Error ? err.message : String(err);
return;
}
if (this.config.expectConfigError) {
this._configError = `Expected config error for ${this.config.heading}`;
}
}
render() {
return html`
@@ -40,15 +63,20 @@ class DemoCard extends LitElement {
: ""
}
</h2>
${
this._configError
? html`<ha-alert alert-type="error">${this._configError}</ha-alert>`
: nothing
}
<div class="root">
<hui-card
.config=${this._config(this.config.config)}
.config=${this.config.config}
.hass=${this.hass}
@card-updated=${this._cardUpdated}
></hui-card>
${
this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
? html`<pre>${this._yamlConfig(this.config.config)}</pre>`
: nothing
}
</div>
@@ -81,6 +109,9 @@ class DemoCard extends LitElement {
font-size: 0.5em;
color: var(--primary-text-color);
}
ha-alert {
margin-bottom: 16px;
}
hui-card {
max-width: 400px;
width: 100vw;
+30 -30
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { AlarmPanelCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -40,52 +42,50 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic Example",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm",
},
},
{
heading: "With Title",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm_armed
name: My Alarm
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm_armed",
name: "My Alarm",
},
},
{
heading: "Code Example",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm_code
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm_code",
},
},
{
heading: "Using only Arm_Home State",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm
states:
- arm_home
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm",
states: ["arm_home"],
},
},
{
heading: "Unavailable",
config: `
- type: alarm-panel
entity: alarm_control_panel.unavailable
states:
- arm_home
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.unavailable",
states: ["arm_home"],
},
},
{
heading: "Invalid Entity",
config: `
- type: alarm-panel
entity: alarm_control_panel.alarm1
`,
config: {
type: "alarm-panel",
entity: "alarm_control_panel.alarm1",
},
},
];
] satisfies DemoCardConfig<AlarmPanelCardConfig>[];
@customElement("demo-lovelace-alarm-panel-card")
class DemoAlarmPanelEntity extends LitElement {
+19 -17
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { AreaCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -80,33 +82,33 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Bedroom",
config: `
- type: area
area: bedroom
`,
config: {
type: "area",
area: "bedroom",
},
},
{
heading: "Living Room",
config: `
- type: area
area: living_room
`,
config: {
type: "area",
area: "living_room",
},
},
{
heading: "Office",
config: `
- type: area
area: office
`,
config: {
type: "area",
area: "office",
},
},
{
heading: "Kitchen",
config: `
- type: area
area: kitchen
`,
config: {
type: "area",
area: "kitchen",
},
},
];
] satisfies DemoCardConfig<AreaCardConfig>[];
@customElement("demo-lovelace-area-card")
class DemoArea extends LitElement {
+32 -25
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
ConditionalCardConfig,
EntitiesCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -39,35 +44,37 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Controller",
config: `
- type: entities
entities:
- light.controller_1
- light.controller_2
- type: divider
- light.floor
- light.kitchen
`,
config: {
type: "entities",
entities: [
"light.controller_1",
"light.controller_2",
{ type: "divider" },
"light.floor",
"light.kitchen",
],
},
},
{
heading: "Demo",
config: `
- type: conditional
conditions:
- entity: light.controller_1
state: "on"
- entity: light.controller_2
state_not: "off"
card:
type: entities
entities:
- light.controller_1
- light.controller_2
- light.floor
- light.kitchen
`,
config: {
type: "conditional",
conditions: [
{ entity: "light.controller_1", state: "on" },
{ entity: "light.controller_2", state_not: "off" },
],
card: {
type: "entities",
entities: [
"light.controller_1",
"light.controller_2",
"light.floor",
"light.kitchen",
],
},
},
},
];
] satisfies DemoCardConfig<EntitiesCardConfig | ConditionalCardConfig>[];
@customElement("demo-lovelace-conditional-card")
class DemoConditional extends LitElement {
+166 -135
View File
@@ -1,7 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
EntitiesCardConfig,
EntitiesCardEntityConfig,
} from "../../../../src/panels/lovelace/cards/types";
import type { CallServiceConfig } from "../../../../src/panels/lovelace/entity-rows/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -254,169 +260,194 @@ const ENTITIES = [
},
];
type GalleryEntitiesCardConfig = Omit<EntitiesCardConfig, "entities"> & {
type: EntitiesCardConfig["type"];
entities: (
| EntitiesCardConfig["entities"][number]
| Pick<EntitiesCardEntityConfig, "entity" | "secondary_info">
| Omit<CallServiceConfig, "entity">
)[];
};
const CONFIGS = [
{
heading: "Basic",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- light.non_existing
- climate.ecobee
- input_number.number
- sensor.humidity
- text.message
- event.doorbell
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"light.non_existing",
"climate.ecobee",
"input_number.number",
"sensor.humidity",
"text.message",
"event.doorbell",
],
},
},
{
heading: "With enabled state color",
config: `
- type: entities
state_color: true
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- light.non_existing
- climate.ecobee
- input_number.number
- sensor.humidity
- text.message
`,
config: {
type: "entities",
state_color: true,
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"light.non_existing",
"climate.ecobee",
"input_number.number",
"sensor.humidity",
"text.message",
],
},
},
{
heading: "Helpers",
config: `
- type: entities
title: Helpers
entities:
- entity: input_boolean.toggle
- entity: input_datetime.date_and_time
- entity: input_number.number
- entity: input_select.dropdown
- entity: input_text.text
- entity: timer.timer
- entity: counter.counter
`,
config: {
type: "entities",
title: "Helpers",
entities: [
{ entity: "input_boolean.toggle" },
{ entity: "input_datetime.date_and_time" },
{ entity: "input_number.number" },
{ entity: "input_select.dropdown" },
{ entity: "input_text.text" },
{ entity: "timer.timer" },
{ entity: "counter.counter" },
],
},
},
{
heading: "With title, toggle-able",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- climate.ecobee
- input_number.number
title: Random group
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"climate.ecobee",
"input_number.number",
],
title: "Random group",
},
},
{
heading: "With title, toggle = false",
config: `
- type: entities
entities:
- scene.romantic_lights
- device_tracker.demo_paulus
- cover.kitchen_window
- group.kitchen
- lock.kitchen_door
- light.bed_light
- climate.ecobee
- input_number.number
title: Random group
show_header_toggle: false
`,
config: {
type: "entities",
entities: [
"scene.romantic_lights",
"device_tracker.demo_paulus",
"cover.kitchen_window",
"group.kitchen",
"lock.kitchen_door",
"light.bed_light",
"climate.ecobee",
"input_number.number",
],
title: "Random group",
show_header_toggle: false,
},
},
{
heading: "With title, can't toggle",
config: `
- type: entities
entities:
- device_tracker.demo_paulus
title: Random group
`,
config: {
type: "entities",
entities: ["device_tracker.demo_paulus"],
title: "Random group",
},
},
{
heading: "Unavailable",
config: `
- type: entities
entities:
- scene.unavailable
- device_tracker.unavailable
- cover.unavailable
- lock.unavailable
- light.unavailable
- climate.unavailable
- input_number.unavailable
- input_select.unavailable
- text.unavailable
- event.unavailable
`,
config: {
type: "entities",
entities: [
"scene.unavailable",
"device_tracker.unavailable",
"cover.unavailable",
"lock.unavailable",
"light.unavailable",
"climate.unavailable",
"input_number.unavailable",
"input_select.unavailable",
"text.unavailable",
"event.unavailable",
],
},
},
{
heading: "Custom name, secondary info, custom icon",
config: `
- type: entities
entities:
- entity: scene.romantic_lights
name: ¯\\_(ツ)_/¯
- entity: device_tracker.demo_paulus
secondary_info: entity-id
- entity: cover.kitchen_window
secondary_info: last-changed
- entity: group.kitchen
icon: mdi:home-assistant
- lock.kitchen_door
- entity: light.bed_light
icon: mdi:alarm-light
name: Bed Light Custom Icon
- climate.ecobee
- input_number.number
title: Random group
show_header_toggle: false
`,
config: {
type: "entities",
entities: [
{ entity: "scene.romantic_lights", name: "¯\\_(ツ)_/¯" },
{
entity: "device_tracker.demo_paulus",
secondary_info: "entity-id",
},
{
entity: "cover.kitchen_window",
secondary_info: "last-changed",
},
{ entity: "group.kitchen", icon: "mdi:home-assistant" },
"lock.kitchen_door",
{
entity: "light.bed_light",
icon: "mdi:alarm-light",
name: "Bed Light Custom Icon",
},
"climate.ecobee",
"input_number.number",
],
title: "Random group",
show_header_toggle: false,
},
},
{
heading: "Special rows",
config: `
- type: entities
entities:
- type: perform-action
icon: mdi:power
name: Bed light
action_name: Toggle light
action: light.toggle
data:
entity_id: light.bed_light
- type: section
label: Links
- type: weblink
url: http://google.com/
icon: mdi:google
name: Google
- type: divider
- type: divider
style:
height: 30px
margin: 4px 0
background: center / contain url("/images/divider.png") no-repeat
`,
config: {
type: "entities",
entities: [
{
type: "perform-action",
icon: "mdi:power",
name: "Bed light",
action_name: "Toggle light",
action: "light.toggle",
data: { entity_id: "light.bed_light" },
},
{ type: "section", label: "Links" },
{
type: "weblink",
url: "http://google.com/",
icon: "mdi:google",
name: "Google",
},
{ type: "divider" },
{
type: "divider",
style: {
height: "30px",
margin: "4px 0",
background: 'center / contain url("/images/divider.png") no-repeat',
},
},
],
},
},
];
] satisfies DemoCardConfig<GalleryEntitiesCardConfig>[];
@customElement("demo-lovelace-entities-card")
class DemoEntities extends LitElement {
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { ButtonCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -18,60 +20,64 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: button
entity: light.bed_light
`,
config: {
type: "button",
entity: "light.bed_light",
},
},
{
heading: "With Name (defined in card)",
config: `
- type: button
name: Custom Name
entity: light.bed_light
`,
config: {
type: "button",
name: "Custom Name",
entity: "light.bed_light",
},
},
{
heading: "With Icon",
config: `
- type: button
entity: light.bed_light
icon: mdi:tools
`,
config: {
type: "button",
entity: "light.bed_light",
icon: "mdi:tools",
},
},
{
heading: "With State",
config: `
- type: button
entity: light.bed_light
show_state: true
`,
config: {
type: "button",
entity: "light.bed_light",
show_state: true,
},
},
{
heading: "Custom Tap Action (toggle)",
config: `
- type: button
entity: light.bed_light
tap_action:
action: toggle
`,
config: {
type: "button",
entity: "light.bed_light",
tap_action: {
action: "toggle",
},
},
},
{
heading: "Running Service",
config: `
- type: button
entity: light.bed_light
service: light.toggle
`,
config: {
type: "button",
entity: "light.bed_light",
tap_action: {
action: "perform-action",
perform_action: "light.toggle",
},
},
},
{
heading: "Invalid Entity",
config: `
- type: button
entity: sensor.invalid_entity
`,
config: {
type: "button",
entity: "sensor.invalid_entity",
},
},
];
] satisfies DemoCardConfig<ButtonCardConfig>[];
@customElement("demo-lovelace-entity-button-card")
class DemoButtonEntity extends LitElement {
+160 -141
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
EntitiesCardConfig,
EntityFilterCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -114,184 +119,198 @@ const ENTITIES = [
},
];
const CONFIGS = [
type StateFilterEntityFilterCardConfig = Pick<
EntityFilterCardConfig,
"type" | "entities" | "card" | "show_empty"
> & {
conditions?: never;
state_filter: NonNullable<EntityFilterCardConfig["state_filter"]>;
};
const VALID_CONFIGS = [
{
heading: "Unfiltered entities",
config: `
- type: entities
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
`,
config: {
type: "entities",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
},
},
{
heading: "On and home entities",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
},
},
{
heading: "Same state as Bed Light",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- light.bed_light
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["light.bed_light"] }],
},
},
{
heading: 'With "entities" card config',
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
card:
type: entities
title: Custom Title
show_header_toggle: false
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
card: {
type: "entities",
title: "Custom Title",
show_header_toggle: false,
},
},
},
{
heading: 'With "glance" card config',
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- "on"
- home
card:
type: glance
show_state: true
title: Custom Title
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
"light.bed_light",
"light.ceiling_lights",
"light.kitchen_lights",
],
conditions: [{ condition: "state", state: ["on", "home"] }],
card: {
type: "glance",
show_state: true,
title: "Custom Title",
},
},
},
{
heading:
"Filtered entities by battery attribute (< '30') using state filter",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
state_filter:
- operator: <
attribute: battery
value: "30"
`,
config: {
type: "entity-filter",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
state_filter: [{ operator: "<", attribute: "battery", value: "30" }],
},
},
{
heading: "Unfiltered number entities",
config: `
- type: entities
entities:
- input_number.min_battery_level
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
`,
config: {
type: "entities",
entities: [
"input_number.min_battery_level",
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
},
},
{
heading: "Battery lower than 50%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: 50
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [{ condition: "numeric_state", below: 50 }],
},
},
{
heading: "Battery lower than min battery level",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: input_number.min_battery_level
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [
{ condition: "numeric_state", below: "input_number.min_battery_level" },
],
},
},
{
heading: "Battery between min battery level and 70%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
above: input_number.min_battery_level
below: 70
`,
config: {
type: "entity-filter",
entities: [
"sensor.battery_1",
"sensor.battery_3",
"sensor.battery_2",
"sensor.battery_4",
],
conditions: [
{
condition: "numeric_state",
above: "input_number.min_battery_level",
below: 70,
},
],
},
},
] satisfies DemoCardConfig<
| EntitiesCardConfig
| EntityFilterCardConfig
| StateFilterEntityFilterCardConfig
>[];
const INVALID_CONFIGS = [
{
heading: "Error: Entities must be specified",
config: `
- type: entity-filter
`,
config: { type: "entity-filter" },
expectConfigError: true,
},
{
heading: "Error: Incorrect filter config",
config: `
- type: entity-filter
entities:
- sensor.gas_station_lowest_price
`,
config: {
type: "entity-filter",
entities: ["sensor.gas_station_lowest_price"],
},
expectConfigError: true,
},
];
] satisfies DemoCardConfig<
| Pick<EntityFilterCardConfig, "type">
| Pick<EntityFilterCardConfig, "type" | "entities">
>[];
const CONFIGS = [...VALID_CONFIGS, ...INVALID_CONFIGS];
@customElement("demo-lovelace-entity-filter-card")
class DemoEntityFilter extends LitElement {
+115 -115
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { GaugeCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -30,158 +32,156 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: gauge
entity: sensor.outside_humidity
name: Outside Humidity
`,
config: {
type: "gauge",
entity: "sensor.outside_humidity",
name: "Outside Humidity",
},
},
{
heading: "Custom unit of measurement",
config: `
- type: gauge
entity: sensor.outside_temperature
unit_of_measurement: C
name: Outside Temperature
`,
config: {
type: "gauge",
entity: "sensor.outside_temperature",
unit: "C",
name: "Outside Temperature",
},
},
{
heading: "Rendering needle",
config: `
- type: gauge
entity: sensor.outside_humidity
name: Outside Humidity
needle: true
`,
config: {
type: "gauge",
entity: "sensor.outside_humidity",
name: "Outside Humidity",
needle: true,
},
},
{
heading: "Rendering needle and severity levels",
config: `
- type: gauge
entity: sensor.brightness_high
name: Brightness High
needle: true
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
name: "Brightness High",
needle: true,
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness
name: Brightness Low
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness",
name: "Brightness Low",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness_medium
name: Brightness Medium
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_medium",
name: "Brightness Medium",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting severity levels",
config: `
- type: gauge
entity: sensor.brightness_high
name: Brightness High
severity:
red: 75
green: 0
yellow: 50
`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
name: "Brightness High",
severity: {
red: 75,
green: 0,
yellow: 50,
},
},
},
{
heading: "Setting min (0) and mx (15) values",
config: `
- type: gauge
entity: sensor.brightness
name: Brightness
min: 0
max: 15
`,
config: {
type: "gauge",
entity: "sensor.brightness",
name: "Brightness",
min: 0,
max: 15,
},
},
{
heading: "Invalid entity",
config: `
- type: gauge
entity: sensor.invalid_entity
`,
config: {
type: "gauge",
entity: "sensor.invalid_entity",
},
},
{
heading: "Non-numeric value",
config: `
- type: gauge
entity: plant.bonsai
`,
config: {
type: "gauge",
entity: "plant.bonsai",
},
},
{
heading: "Unavailable entity",
config: `
- type: gauge
entity: sensor.not_working
`,
config: {
type: "gauge",
entity: "sensor.not_working",
},
},
{
heading: "Lower minimum",
config: `
- type: gauge
entity: sensor.brightness_high
needle: true
severity:
green: 0
yellow: 0.45
red: 0.9
min: -0.05
name: " "
max: 1.9
unit: GBP/h`,
config: {
type: "gauge",
entity: "sensor.brightness_high",
needle: true,
severity: {
green: 0,
yellow: 0.45,
red: 0.9,
},
min: -0.05,
name: " ",
max: 1.9,
unit: "GBP/h",
},
},
{
heading: "A lot of segments",
config: `
- type: gauge
needle: true
name: Percent gauge
entity: sensor.brightness_high
unit: "%"
min: 0
max: 100
segments:
- from: 0
color: "#db4437"
- from: 10
color: "#cc4d39"
- from: 20
color: "#bd563a"
- from: 30
color: "#ad603c"
- from: 40
color: "#9e693d"
- from: 50
color: "#8f723f"
- from: 60
color: "#807b41"
- from: 70
color: "#718442"
- from: 80
color: "#618e44"
- from: 90
color: "#43a047"`,
config: {
type: "gauge",
needle: true,
name: "Percent gauge",
entity: "sensor.brightness_high",
unit: "%",
min: 0,
max: 100,
segments: [
{ from: 0, color: "#db4437" },
{ from: 10, color: "#cc4d39" },
{ from: 20, color: "#bd563a" },
{ from: 30, color: "#ad603c" },
{ from: 40, color: "#9e693d" },
{ from: 50, color: "#8f723f" },
{ from: 60, color: "#807b41" },
{ from: 70, color: "#718442" },
{ from: 80, color: "#618e44" },
{ from: 90, color: "#43a047" },
],
},
},
];
] satisfies DemoCardConfig<GaugeCardConfig>[];
@customElement("demo-lovelace-gauge-card")
class DemoGaugeEntity extends LitElement {
+161 -135
View File
@@ -1,7 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GlanceCardConfig,
GlanceConfigEntity,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -86,172 +91,193 @@ const ENTITIES = [
},
];
type LegacyNullNameGlanceCardConfig = Omit<GlanceCardConfig, "entities"> & {
type: GlanceCardConfig["type"];
entities: (
| string
| GlanceConfigEntity
| (Omit<GlanceConfigEntity, "name"> & { name: null })
)[];
};
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: glance
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No state colors",
config: `
- type: glance
state_color: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
state_color: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "With title",
config: `
- type: glance
title: Custom title
columns: 4
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
title: "Custom title",
columns: 4,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "Custom number of columns",
config: `
- type: glance
columns: 7
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 7,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No entity names",
config: `
- type: glance
columns: 4
show_name: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_name: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No state labels",
config: `
- type: glance
columns: 4
show_state: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_state: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "No names and no state labels",
config: `
- type: glance
columns: 4
show_name: false
show_state: false
entities:
- device_tracker.demo_paulus
- media_player.living_room
- sun.sun
- cover.kitchen_window
- light.kitchen_lights
- lock.kitchen_door
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
show_name: false,
show_state: false,
entities: [
"device_tracker.demo_paulus",
"media_player.living_room",
"sun.sun",
"cover.kitchen_window",
"light.kitchen_lights",
"lock.kitchen_door",
"light.ceiling_lights",
],
},
},
{
heading: "Custom name + custom icon",
config: `
- type: glance
columns: 4
entities:
- entity: device_tracker.demo_paulus
name: ¯\\_(ツ)_/¯
icon: mdi:home-assistant
- entity: media_player.living_room
name: ¯\\_(ツ)_/¯
icon: mdi:home-assistant
`,
config: {
type: "glance",
columns: 4,
entities: [
{
entity: "device_tracker.demo_paulus",
name: "¯\\_(ツ)_/¯",
icon: "mdi:home-assistant",
},
{
entity: "media_player.living_room",
name: "¯\\_(ツ)_/¯",
icon: "mdi:home-assistant",
},
],
},
},
{
heading: "Selectively hidden name",
config: `
- type: glance
columns: 4
entities:
- device_tracker.demo_paulus
- entity: media_player.living_room
name:
- sun.sun
- entity: cover.kitchen_window
name:
- light.kitchen_lights
- entity: lock.kitchen_door
name:
- light.ceiling_lights
`,
config: {
type: "glance",
columns: 4,
entities: [
"device_tracker.demo_paulus",
{ entity: "media_player.living_room", name: null },
"sun.sun",
{ entity: "cover.kitchen_window", name: null },
"light.kitchen_lights",
{ entity: "lock.kitchen_door", name: null },
"light.ceiling_lights",
],
},
},
{
heading: "Custom tap action",
config: `
- type: glance
columns: 4
entities:
- entity: lock.kitchen_door
name: Custom
tap_action:
type: toggle
- entity: light.ceiling_lights
name: Custom
tap_action:
action: call-service
service: light.turn_on
data:
entity_id: light.ceiling_lights
- entity: sun.sun
name: Regular
- entity: light.kitchen_lights
name: Regular
`,
config: {
type: "glance",
columns: 4,
entities: [
{
entity: "lock.kitchen_door",
name: "Custom",
tap_action: { action: "toggle" },
},
{
entity: "light.ceiling_lights",
name: "Custom",
tap_action: {
action: "perform-action",
perform_action: "light.turn_on",
data: { entity_id: "light.ceiling_lights" },
},
},
{ entity: "sun.sun", name: "Regular" },
{ entity: "light.kitchen_lights", name: "Regular" },
],
},
},
];
] satisfies DemoCardConfig<GlanceCardConfig | LegacyNullNameGlanceCardConfig>[];
@customElement("demo-lovelace-glance-card")
class DemoGlanceEntity extends LitElement {
+129 -124
View File
@@ -1,8 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GridCardConfig,
StackCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -70,159 +75,159 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Default Grid",
config: `
- type: grid
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
- type: entity
entity: device_tracker.demo_anne_therese
`,
config: {
type: "grid",
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
],
},
},
{
heading: "Non-square Grid with 2 columns",
config: `
- type: grid
columns: 2
square: false
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
`,
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
],
},
},
{
heading: "Default Grid with title",
config: `
- type: grid
title: Kitchen
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
- type: entity
entity: device_tracker.demo_anne_therese
`,
config: {
type: "grid",
title: "Kitchen",
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
],
},
},
{
heading: "Columns 4",
config: `
- type: grid
columns: 4
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
- type: entity
entity: device_tracker.demo_paulus
- type: sensor
entity: sensor.illumination
graph: line
`,
config: {
type: "grid",
columns: 4,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
{ type: "entity", entity: "device_tracker.demo_paulus" },
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
],
},
},
{
heading: "Columns 2",
config: `
- type: grid
columns: 2
cards:
- type: entity
entity: light.kitchen_lights
- type: entity
entity: light.bed_light
`,
config: {
type: "grid",
columns: 2,
cards: [
{ type: "entity", entity: "light.kitchen_lights" },
{ type: "entity", entity: "light.bed_light" },
],
},
},
{
heading: "Columns 1",
config: `
- type: grid
columns: 1
cards:
- type: entity
entity: light.kitchen_lights
`,
config: {
type: "grid",
columns: 1,
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
},
},
{
heading: "Size for single card",
config: `
- type: grid
cards:
- type: entity
entity: light.kitchen_lights
`,
config: {
type: "grid",
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
},
},
{
heading: "Vertical Stack",
config: `
- type: vertical-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
`,
config: {
type: "vertical-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
},
{
heading: "Horizontal Stack",
config: `
- type: horizontal-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
`,
config: {
type: "horizontal-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
},
{
heading: "Combination of both",
config: `
- type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
- type: glance
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- type: picture-entity
image: /images/bed.png
entity: light.bed_light
`,
config: {
type: "vertical-stack",
cards: [
{
type: "horizontal-stack",
cards: [
{
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
},
{
type: "glance",
entities: [
"device_tracker.demo_anne_therese",
"device_tracker.demo_home_boy",
"device_tracker.demo_paulus",
],
},
],
},
{
type: "picture-entity",
image: "/images/bed.png",
entity: "light.bed_light",
},
],
},
},
];
] satisfies DemoCardConfig<GridCardConfig | StackCardConfig>[];
@customElement("demo-lovelace-grid-and-stack-card")
class DemoStack extends LitElement {
+22 -20
View File
@@ -1,42 +1,44 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { IframeCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
const CONFIGS = [
{
heading: "Without title",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
},
},
{
heading: "With title",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
title: Weather radar
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
title: "Weather radar",
},
},
{
heading: "Height-Width 3:4",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
aspect_ratio: 75%
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
aspect_ratio: "75%",
},
},
{
heading: "Height-Width 1:1",
config: `
- type: iframe
url: https://embed.windy.com/embed2.html
aspect_ratio: 100%
`,
config: {
type: "iframe",
url: "https://embed.windy.com/embed2.html",
aspect_ratio: "100%",
},
},
];
] satisfies DemoCardConfig<IframeCardConfig>[];
@customElement("demo-lovelace-iframe-card")
class DemoIframe extends LitElement {
+23 -21
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { LightCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -44,40 +46,40 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Switchable Light",
config: `
- type: light
entity: light.bed_light
`,
config: {
type: "light",
entity: "light.bed_light",
},
},
{
heading: "Dimmable Light On",
config: `
- type: light
entity: light.dim_on
`,
config: {
type: "light",
entity: "light.dim_on",
},
},
{
heading: "Dimmable Light Off",
config: `
- type: light
entity: light.dim_off
`,
config: {
type: "light",
entity: "light.dim_off",
},
},
{
heading: "Unavailable",
config: `
- type: light
entity: light.unavailable
`,
config: {
type: "light",
entity: "light.unavailable",
},
},
{
heading: "Non existing",
config: `
- type: light
entity: light.nonexisting
`,
config: {
type: "light",
entity: "light.nonexisting",
},
},
];
] satisfies DemoCardConfig<LightCardConfig>[];
@customElement("demo-lovelace-light-card")
class DemoLightEntity extends LitElement {
+58 -70
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MapCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
const ENTITIES = [
@@ -86,107 +88,93 @@ 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"],
},
},
];
] satisfies DemoCardConfig<MapCardConfig>[];
@customElement("demo-lovelace-map-card")
class DemoMap extends LitElement {
+10 -6
View File
@@ -1,17 +1,18 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { mockTemplate } from "../../../../demo/src/stubs/template";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MarkdownCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
const CONFIGS = [
{
heading: "markdown-it demo",
config: `
- type: markdown
content: |
# h1 Heading 8-)
config: {
type: "markdown",
content: `# h1 Heading 8-)
## h2 Heading
@@ -278,9 +279,12 @@ const CONFIGS = [
<ha-alert alert-type="success">This is a success alert — check it out!</ha-alert>
<ha-alert title="Test alert">This is an alert with a title</ha-alert>
`,
`
.replace(/^ {4}/gm, "")
.replace(/\n+$/, "\n"),
},
},
];
] satisfies DemoCardConfig<MarkdownCardConfig>[];
@customElement("demo-lovelace-markdown-card")
class DemoMarkdown extends LitElement {
@@ -1,162 +1,165 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type {
GridCardConfig,
MediaControlCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { createMediaPlayerEntities } from "../../data/media_players";
const CONFIGS = [
{
heading: "Paused Music",
config: `
- type: media-control
entity: media_player.music_paused
`,
config: {
type: "media-control",
entity: "media_player.music_paused",
},
},
{
heading: "Playing Music",
config: `
- type: media-control
entity: media_player.music_playing
`,
config: {
type: "media-control",
entity: "media_player.music_playing",
},
},
{
heading: "Playing Stream",
config: `
- type: media-control
entity: media_player.stream_playing
`,
config: {
type: "media-control",
entity: "media_player.stream_playing",
},
},
{
heading: "Paused Stream",
config: `
- type: media-control
entity: media_player.stream_paused
`,
config: {
type: "media-control",
entity: "media_player.stream_paused",
},
},
{
heading: 'Playing Stream (with "previous" support)',
config: `
- type: media-control
entity: media_player.stream_playing_previous
`,
config: {
type: "media-control",
entity: "media_player.stream_playing_previous",
},
},
{
heading: "Playing non-skip TV Show",
config: `
- type: media-control
entity: media_player.tv_playing
`,
config: {
type: "media-control",
entity: "media_player.tv_playing",
},
},
{
heading: "Screen Casting",
config: `
- type: media-control
entity: media_player.android_cast
`,
config: {
type: "media-control",
entity: "media_player.android_cast",
},
},
{
heading: "Digital Picture Frame",
config: `
- type: media-control
entity: media_player.image_display
`,
config: {
type: "media-control",
entity: "media_player.image_display",
},
},
{
heading: "Sonos Idle",
config: `
- type: media-control
entity: media_player.sonos_idle
`,
config: {
type: "media-control",
entity: "media_player.sonos_idle",
},
},
{
heading: "Idle waiting for Browse Media",
config: `
- type: media-control
entity: media_player.idle_browse_media
`,
config: {
type: "media-control",
entity: "media_player.idle_browse_media",
},
},
{
heading: "Player Off",
config: `
- type: media-control
entity: media_player.theater_off
`,
config: {
type: "media-control",
entity: "media_player.theater_off",
},
},
{
heading: "Player On",
config: `
- type: media-control
entity: media_player.theater_on
`,
config: {
type: "media-control",
entity: "media_player.theater_on",
},
},
{
heading: "Player Off (cannot be switched on)",
config: `
- type: media-control
entity: media_player.theater_off_static
`,
config: {
type: "media-control",
entity: "media_player.theater_off_static",
},
},
{
heading: "Player On (cannot be switched off)",
config: `
- type: media-control
entity: media_player.theater_on_static
`,
config: {
type: "media-control",
entity: "media_player.theater_on_static",
},
},
{
heading: "Player Idle",
config: `
- type: media-control
entity: media_player.idle
`,
config: {
type: "media-control",
entity: "media_player.idle",
},
},
{
heading: "Player Playing",
config: `
- type: media-control
entity: media_player.playing
`,
config: {
type: "media-control",
entity: "media_player.playing",
},
},
{
heading: "Player Unavailable",
config: `
- type: media-control
entity: media_player.unavailable
`,
config: {
type: "media-control",
entity: "media_player.unavailable",
},
},
{
heading: "Player Unknown",
config: `
- type: media-control
entity: media_player.unknown
`,
config: {
type: "media-control",
entity: "media_player.unknown",
},
},
{
heading: "Receiver On (selectable sources)",
config: `
- type: media-control
entity: media_player.receiver_on
`,
config: {
type: "media-control",
entity: "media_player.receiver_on",
},
},
{
heading: "Receiver Off (selectable sources)",
config: `
- type: media-control
entity: media_player.receiver_off
`,
config: {
type: "media-control",
entity: "media_player.receiver_off",
},
},
{
heading: "Grid Full Size",
config: `
- type: grid
columns: 1
cards:
- type: media-control
entity: media_player.music_paused
`,
config: {
type: "grid",
columns: 1,
cards: [{ type: "media-control", entity: "media_player.music_paused" }],
},
},
];
] satisfies DemoCardConfig<MediaControlCardConfig | GridCardConfig>[];
@customElement("demo-lovelace-media-control-card")
class DemoHuiMediaControlCard extends LitElement {
+46 -45
View File
@@ -1,59 +1,60 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { EntitiesCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { createMediaPlayerEntities } from "../../data/media_players";
const CONFIGS = [
{
heading: "Media Players",
config: `
- type: entities
entities:
- entity: media_player.music_paused
name: Paused Music
- entity: media_player.music_playing
name: Playing Music
- entity: media_player.stream_playing
name: Playing Stream
- entity: media_player.stream_paused
name: Paused Stream
- entity: media_player.stream_playing_previous
name: Playing Stream (with "previous" support)
- entity: media_player.tv_playing
name: Playing non-skip TV Show
- entity: media_player.android_cast
name: Screen casting
- entity: media_player.image_display
name: Digital Picture Frame
- entity: media_player.sonos_idle
name: Sonos Idle
- entity: media_player.idle_browse_media
name: Idle waiting for Browse Media
- entity: media_player.theater_off
name: Player Off
- entity: media_player.theater_on
name: Player On
- entity: media_player.theater_off_static
name: Player Off (cannot be switched on)
- entity: media_player.theater_on_static
name: Player On (cannot be switched off)
- entity: media_player.idle
name: Player Idle
- entity: media_player.playing
name: Player Playing
- entity: media_player.unavailable
name: Player Unavailable
- entity: media_player.unknown
name: Player Unknown
- entity: media_player.receiver_on
name: Receiver On (selectable sources)
- entity: media_player.receiver_off
name: Receiver Off (selectable sources)
`,
config: {
type: "entities",
entities: [
{ entity: "media_player.music_paused", name: "Paused Music" },
{ entity: "media_player.music_playing", name: "Playing Music" },
{ entity: "media_player.stream_playing", name: "Playing Stream" },
{ entity: "media_player.stream_paused", name: "Paused Stream" },
{
entity: "media_player.stream_playing_previous",
name: 'Playing Stream (with "previous" support)',
},
{ entity: "media_player.tv_playing", name: "Playing non-skip TV Show" },
{ entity: "media_player.android_cast", name: "Screen casting" },
{ entity: "media_player.image_display", name: "Digital Picture Frame" },
{ entity: "media_player.sonos_idle", name: "Sonos Idle" },
{
entity: "media_player.idle_browse_media",
name: "Idle waiting for Browse Media",
},
{ entity: "media_player.theater_off", name: "Player Off" },
{ entity: "media_player.theater_on", name: "Player On" },
{
entity: "media_player.theater_off_static",
name: "Player Off (cannot be switched on)",
},
{
entity: "media_player.theater_on_static",
name: "Player On (cannot be switched off)",
},
{ entity: "media_player.idle", name: "Player Idle" },
{ entity: "media_player.playing", name: "Player Playing" },
{ entity: "media_player.unavailable", name: "Player Unavailable" },
{ entity: "media_player.unknown", name: "Player Unknown" },
{
entity: "media_player.receiver_on",
name: "Receiver On (selectable sources)",
},
{
entity: "media_player.receiver_off",
name: "Receiver Off (selectable sources)",
},
],
},
},
];
] satisfies DemoCardConfig<EntitiesCardConfig>[];
@customElement("demo-lovelace-media-player-row")
export class DemoLovelaceMediaPlayerRow extends LitElement {
+15 -13
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { PictureCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -19,26 +21,26 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Image URL",
config: `
- type: picture
image: /images/living_room.png
`,
config: {
type: "picture",
image: "/images/living_room.png",
},
},
{
heading: "Person entity",
config: `
- type: picture
image_entity: person.paulus
`,
config: {
type: "picture",
image_entity: "person.paulus",
},
},
{
heading: "Error: Image required",
config: `
- type: picture
entity: person.paulus
`,
config: {
type: "picture",
},
expectConfigError: true,
},
];
] satisfies DemoCardConfig<PictureCardConfig>[];
@customElement("demo-lovelace-picture-card")
class DemoPicture extends LitElement {
@@ -1,7 +1,13 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureElementsCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type {
ImageElementConfig,
LovelaceElementConfig,
} from "../../../../src/panels/lovelace/elements/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -60,116 +66,141 @@ const ENTITIES = [
},
];
type LegacyImageElementConfig = Omit<
ImageElementConfig,
"state_filter" | "state_image"
> & {
state_filter?: Record<string, string>;
state_image?: Record<string, string>;
};
type GalleryPictureElementsCardConfig = Omit<
PictureElementsCardConfig,
"elements"
> & {
type: PictureElementsCardConfig["type"];
elements: (LovelaceElementConfig | LegacyImageElementConfig)[];
};
const CONFIGS = [
{
heading: "Card with few elements",
config: `
- type: picture-elements
image: /images/floorplan.png
elements:
- type: service-button
title: Lights Off
style:
top: 97%
left: 90%
padding: 0px
service: light.turn_off
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
entity: camera.demo_camera
style:
top: 12%
left: 6%
transform: rotate(-60deg) scaleX(-1)
--mdc-icon-size: 30px
--mdc-icon-stroke-color: black
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
- type: image
entity: light.bed_light
tap_action:
action: toggle
image: /images/light_bulb_off.png
state_image:
'on': /images/light_bulb_on.png
state_filter:
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
'off': brightness(80%) saturate(0.8)
style:
top: 35%
left: 65%
width: 7%
padding: 50px 50px 100px 50px
- type: state-icon
entity: binary_sensor.movement_backyard
style:
top: 8%
left: 35%
`,
config: {
type: "picture-elements",
image: "/images/floorplan.png",
elements: [
{
type: "service-button",
title: "Lights Off",
style: { top: "97%", left: "90%", padding: "0px" },
service: "light.turn_off",
data: { entity_id: "group.all_lights" },
},
{
type: "icon",
icon: "mdi:cctv",
entity: "camera.demo_camera",
style: {
top: "12%",
left: "6%",
transform: "rotate(-60deg) scaleX(-1)",
"--mdc-icon-size": "30px",
"--mdc-icon-stroke-color": "black",
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
},
},
{
type: "image",
entity: "light.bed_light",
tap_action: { action: "toggle" },
image: "/images/light_bulb_off.png",
state_image: { on: "/images/light_bulb_on.png" },
state_filter: {
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
off: "brightness(80%) saturate(0.8)",
},
style: {
top: "35%",
left: "65%",
width: "7%",
padding: "50px 50px 100px 50px",
},
},
{
type: "state-icon",
entity: "binary_sensor.movement_backyard",
style: { top: "8%", left: "35%" },
},
],
},
},
{
heading: "Card with header",
config: `
- type: picture-elements
image: /images/floorplan.png
title: My House
elements:
- type: service-button
title: Lights Off
style:
top: 97%
left: 90%
padding: 0px
service: light.turn_off
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
entity: camera.demo_camera
style:
top: 12%
left: 6%
transform: rotate(-60deg) scaleX(-1)
--mdc-icon-size: 30px
--mdc-icon-stroke-color: black
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
- type: image
entity: light.bed_light
tap_action:
action: toggle
image: /images/light_bulb_off.png
state_image:
'on': /images/light_bulb_on.png
state_filter:
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
'off': brightness(80%) saturate(0.8)
style:
top: 35%
left: 65%
width: 7%
padding: 50px 50px 100px 50px
- type: state-icon
entity: binary_sensor.movement_backyard
style:
top: 8%
left: 35%
`,
config: {
type: "picture-elements",
image: "/images/floorplan.png",
title: "My House",
elements: [
{
type: "service-button",
title: "Lights Off",
style: { top: "97%", left: "90%", padding: "0px" },
service: "light.turn_off",
data: { entity_id: "group.all_lights" },
},
{
type: "icon",
icon: "mdi:cctv",
entity: "camera.demo_camera",
style: {
top: "12%",
left: "6%",
transform: "rotate(-60deg) scaleX(-1)",
"--mdc-icon-size": "30px",
"--mdc-icon-stroke-color": "black",
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
},
},
{
type: "image",
entity: "light.bed_light",
tap_action: { action: "toggle" },
image: "/images/light_bulb_off.png",
state_image: { on: "/images/light_bulb_on.png" },
state_filter: {
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
off: "brightness(80%) saturate(0.8)",
},
style: {
top: "35%",
left: "65%",
width: "7%",
padding: "50px 50px 100px 50px",
},
},
{
type: "state-icon",
entity: "binary_sensor.movement_backyard",
style: { top: "8%", left: "35%" },
},
],
},
},
{
heading: "Person entity",
config: `
- type: picture-elements
image_entity: person.paulus
elements:
- type: state-icon
entity: sensor.battery
style:
top: 8%
left: 8%
`,
config: {
type: "picture-elements",
image_entity: "person.paulus",
elements: [
{
type: "state-icon",
entity: "sensor.battery",
style: { top: "8%", left: "8%" },
},
],
},
},
];
] satisfies DemoCardConfig<GalleryPictureElementsCardConfig>[];
@customElement("demo-lovelace-picture-elements-card")
class DemoPictureElements extends LitElement {
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureEntityCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -33,75 +35,73 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "State on",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
tap_action:
action: toggle
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
tap_action: { action: "toggle" },
},
},
{
heading: "State off",
config: `
- type: picture-entity
image: /images/bed.png
entity: light.bed_light
tap_action:
action: toggle
`,
config: {
type: "picture-entity",
image: "/images/bed.png",
entity: "light.bed_light",
tap_action: { action: "toggle" },
},
},
{
heading: "Entity unavailable",
config: `
- type: picture-entity
image: /images/living_room.png
entity: light.non_existing
`,
config: {
type: "picture-entity",
image: "/images/living_room.png",
entity: "light.non_existing",
},
},
{
heading: "Camera entity",
config: `
- type: picture-entity
entity: camera.demo_camera
`,
config: {
type: "picture-entity",
entity: "camera.demo_camera",
},
},
{
heading: "Person entity",
config: `
- type: picture-entity
entity: person.paulus
`,
config: {
type: "picture-entity",
entity: "person.paulus",
},
},
{
heading: "Hidden name",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_name: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_name: false,
},
},
{
heading: "Hidden state",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_state: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_state: false,
},
},
{
heading: "Both hidden",
config: `
- type: picture-entity
image: /images/kitchen.png
entity: light.kitchen_lights
show_name: false
show_state: false
`,
config: {
type: "picture-entity",
image: "/images/kitchen.png",
entity: "light.kitchen_lights",
show_name: false,
show_state: false,
},
},
];
] satisfies DemoCardConfig<PictureEntityCardConfig>[];
@customElement("demo-lovelace-picture-entity-card")
class DemoPictureEntity extends LitElement {
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { PictureGlanceCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -58,110 +60,110 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Title, dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- switch.decorative_lights
- light.ceiling_lights
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
"switch.decorative_lights",
"light.ceiling_lights",
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Title, dialog, no toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Title, no dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- switch.decorative_lights
- light.ceiling_lights
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: ["switch.decorative_lights", "light.ceiling_lights"],
},
},
{
heading: "No title, dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- switch.decorative_lights
- light.ceiling_lights
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: [
"switch.decorative_lights",
"light.ceiling_lights",
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "No title, dialog, no toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- binary_sensor.movement_backyard
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: [
"binary_sensor.movement_backyard",
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "No title, no dialog, toggle",
config: `
- type: picture-glance
image: /images/living_room.png
entities:
- switch.decorative_lights
- light.ceiling_lights
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
entities: ["switch.decorative_lights", "light.ceiling_lights"],
},
},
{
heading: "Person entity",
config: `
- type: picture-glance
image_entity: person.paulus
entities:
- sensor.battery
`,
config: {
type: "picture-glance",
image_entity: "person.paulus",
entities: ["sensor.battery"],
},
},
{
heading: "Custom icon",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entities:
- entity: switch.decorative_lights
icon: mdi:power
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entities: [
{ entity: "switch.decorative_lights", icon: "mdi:power" },
"binary_sensor.basement_floor_wet",
],
},
},
{
heading: "Custom tap action",
config: `
- type: picture-glance
image: /images/living_room.png
title: Living room
entity: light.ceiling_lights
tap_action:
action: toggle
entities:
- entity: switch.decorative_lights
icon: mdi:power
tap_action:
action: toggle
- binary_sensor.basement_floor_wet
`,
config: {
type: "picture-glance",
image: "/images/living_room.png",
title: "Living room",
entity: "light.ceiling_lights",
tap_action: { action: "toggle" },
entities: [
{
entity: "switch.decorative_lights",
icon: "mdi:power",
tap_action: { action: "toggle" },
},
"binary_sensor.basement_floor_wet",
],
},
},
];
] satisfies DemoCardConfig<PictureGlanceCardConfig>[];
@customElement("demo-lovelace-picture-glance-card")
class DemoPictureGlance extends LitElement {
+16 -14
View File
@@ -1,6 +1,8 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { PlantStatusCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { createPlantEntities } from "../../data/plants";
@@ -9,27 +11,27 @@ import { mockIcons } from "../../../../demo/src/stubs/icons";
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: plant-status
entity: plant.lemon_tree
`,
config: {
type: "plant-status",
entity: "plant.lemon_tree",
},
},
{
heading: "Problem (too bright) + low battery",
config: `
- type: plant-status
entity: plant.apple_tree
`,
config: {
type: "plant-status",
entity: "plant.apple_tree",
},
},
{
heading: "With picture + multiple problems",
config: `
- type: plant-status
entity: plant.sunflowers
name: Sunflowers Name Overwrite
`,
config: {
type: "plant-status",
entity: "plant.sunflowers",
name: "Sunflowers Name Overwrite",
},
},
];
] satisfies DemoCardConfig<PlantStatusCardConfig>[];
@customElement("demo-lovelace-plant-card")
export class DemoPlantEntity extends LitElement {
+108 -95
View File
@@ -1,7 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ThermostatCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
@@ -123,120 +125,131 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Range example",
config: `
- type: thermostat
entity: climate.ecobee
`,
config: {
type: "thermostat",
entity: "climate.ecobee",
},
},
{
heading: "Single temp example",
config: `
- type: thermostat
entity: climate.nest
`,
config: {
type: "thermostat",
entity: "climate.nest",
},
},
{
heading: "Feature example",
config: `
- type: thermostat
entity: climate.overkiz_radiator
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- auto
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
- type: climate-preset-modes
style: dropdown
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
`,
config: {
type: "thermostat",
entity: "climate.overkiz_radiator",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["heat", "off", "auto"],
},
{
type: "climate-preset-modes",
style: "icons",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
},
{
type: "climate-preset-modes",
style: "dropdown",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
},
],
},
},
{
heading: "Preset only example",
config: `
- type: thermostat
entity: climate.overkiz_towel_dryer
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
`,
config: {
type: "thermostat",
entity: "climate.overkiz_towel_dryer",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["heat", "off"],
},
{
type: "climate-preset-modes",
style: "icons",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
},
],
},
},
{
heading: "Fan only example",
config: `
- type: thermostat
entity: climate.sensibo
features:
- type: climate-hvac-modes
hvac_modes:
- fan_only
- 'off'
- type: climate-fan-modes
style: icons
fan_modes:
- low
- high
- type: climate-swing-modes
style: icons
swing_modes:
- 'both'
- 'rangefull'
- 'off'
swing_horizontal_modes:
- 'both'
- 'rangefull'
- 'off'
`,
config: {
type: "thermostat",
entity: "climate.sensibo",
features: [
{
type: "climate-hvac-modes",
hvac_modes: ["fan_only", "off"],
},
{
type: "climate-fan-modes",
style: "icons",
fan_modes: ["low", "high"],
},
{
type: "climate-swing-modes",
style: "icons",
swing_modes: ["both", "rangefull", "off"],
},
{
type: "climate-swing-horizontal-modes",
style: "icons",
swing_horizontal_modes: ["both", "rangefull", "off"],
},
],
},
},
{
heading: "Unavailable",
config: `
- type: thermostat
entity: climate.unavailable
`,
config: {
type: "thermostat",
entity: "climate.unavailable",
},
},
{
heading: "Non existing",
config: `
- type: thermostat
entity: climate.nonexisting
`,
config: {
type: "thermostat",
entity: "climate.nonexisting",
},
},
];
] satisfies DemoCardConfig<ThermostatCardConfig>[];
@customElement("demo-lovelace-thermostat-card")
class DemoThermostatEntity extends LitElement {
+114 -123
View File
@@ -1,6 +1,7 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light";
import { LockEntityFeature } from "../../../../src/data/lock";
@@ -11,6 +12,7 @@ import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
const ENTITIES = [
{
@@ -166,190 +168,179 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "Basic example",
config: `
- type: tile
entity: switch.tv_outlet
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
},
},
{
heading: "Vertical example",
config: `
- type: tile
entity: switch.tv_outlet
vertical: true
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
vertical: true,
},
},
{
heading: "Custom color",
config: `
- type: tile
entity: switch.tv_outlet
color: pink
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
color: "pink",
},
},
{
heading: "Whole tile tap action",
config: `
- type: tile
entity: switch.tv_outlet
color: pink
tap_action:
action: toggle
icon_tap_action:
action: none
`,
config: {
type: "tile",
entity: "switch.tv_outlet",
color: "pink",
tap_action: {
action: "toggle",
},
icon_tap_action: {
action: "none",
},
},
},
{
heading: "Unknown entity",
config: `
- type: tile
entity: light.unknown
`,
config: {
type: "tile",
entity: "light.unknown",
},
},
{
heading: "Unavailable entity",
config: `
- type: tile
entity: light.unavailable
`,
config: {
type: "tile",
entity: "light.unavailable",
},
},
{
heading: "Climate",
config: `
- type: tile
entity: climate.thermostat
`,
config: {
type: "tile",
entity: "climate.thermostat",
},
},
{
heading: "Person",
config: `
- type: tile
entity: person.paulus
`,
config: {
type: "tile",
entity: "person.paulus",
},
},
{
heading: "Light brightness feature",
config: `
- type: tile
entity: light.bed_light
features:
- type: "light-brightness"
`,
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-brightness" }],
},
},
{
heading: "Light color temperature feature",
config: `
- type: tile
entity: light.bed_light
features:
- type: "color-temp"
`,
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color-temp" }],
},
},
{
heading: "Lock commands feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-commands"
`,
config: {
type: "tile",
entity: "lock.front_door",
features: [{ type: "lock-commands" }],
},
},
{
heading: "Lock open door feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-open-door"
`,
config: {
type: "tile",
entity: "lock.front_door",
features: [{ type: "lock-open-door" }],
},
},
{
heading: "Media player volume slider feature",
config: `
- type: tile
entity: media_player.living_room
features:
- type: "media-player-volume-slider"
`,
config: {
type: "tile",
entity: "media_player.living_room",
features: [{ type: "media-player-volume-slider" }],
},
},
{
heading: "Vacuum commands feature",
config: `
- type: tile
entity: vacuum.first_floor_vacuum
features:
- type: "vacuum-commands"
commands:
- start_pause
- stop
- return_home
`,
config: {
type: "tile",
entity: "vacuum.first_floor_vacuum",
features: [
{
type: "vacuum-commands",
commands: ["start_pause", "stop", "return_home"],
},
],
},
},
{
heading: "Cover open close feature",
config: `
- type: tile
entity: cover.kitchen_shutter
features:
- type: "cover-open-close"
`,
config: {
type: "tile",
entity: "cover.kitchen_shutter",
features: [{ type: "cover-open-close" }],
},
},
{
heading: "Cover tilt feature",
config: `
- type: tile
entity: cover.pergola_roof
features:
- type: "cover-tilt"
`,
config: {
type: "tile",
entity: "cover.pergola_roof",
features: [{ type: "cover-tilt" }],
},
},
{
heading: "Number buttons feature",
config: `
- type: tile
entity: input_number.counter
features:
- type: numeric-input
style: buttons
`,
config: {
type: "tile",
entity: "input_number.counter",
features: [{ type: "numeric-input", style: "buttons" }],
},
},
{
heading: "Dual thermostat feature",
config: `
- type: tile
entity: climate.dual_thermostat
features:
- type: target-temperature
`,
config: {
type: "tile",
entity: "climate.dual_thermostat",
features: [{ type: "target-temperature" }],
},
},
{
heading: "Fan direction feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-direction
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-direction" }],
},
},
{
heading: "Fan speed feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-speed
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan oscillate feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-oscillate
`,
config: {
type: "tile",
entity: "fan.fan_demo",
features: [{ type: "fan-oscillate" }],
},
},
];
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
class DemoTile extends LitElement {
+12 -10
View File
@@ -4,6 +4,8 @@ import { customElement, query } from "lit/decorators";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { TodoListCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import "../../components/demo-cards";
const ENTITIES = [
@@ -27,20 +29,20 @@ const ENTITIES = [
const CONFIGS = [
{
heading: "List example",
config: `
- type: todo-list
entity: todo.shopping_list
`,
config: {
type: "todo-list",
entity: "todo.shopping_list",
},
},
{
heading: "List with title example",
config: `
- type: todo-list
title: Shopping List
entity: todo.read_only
`,
config: {
type: "todo-list",
title: "Shopping List",
entity: "todo.read_only",
},
},
];
] satisfies DemoCardConfig<TodoListCardConfig>[];
@customElement("demo-lovelace-todo-list-card")
class DemoTodoListEntity extends LitElement {
+2 -3
View File
@@ -147,8 +147,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",
@@ -188,7 +188,6 @@
"glob": "13.0.6",
"globals": "17.8.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
+13
View File
@@ -0,0 +1,13 @@
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
// only between the first and last character. The hyphen is escaped because a
// `pattern` attribute is compiled with the `v` flag, under which a trailing
// unescaped hyphen is an invalid character class — and a pattern that fails to
// compile is silently ignored, disabling validation altogether.
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
// hostname. Deliberately excludes underscores and a trailing dot.
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
+5 -6
View File
@@ -53,8 +53,10 @@ export class HaControlNumberButton extends LitElement {
});
private _boundedValue(value: number) {
const clamped = conditionalClamp(value, this.min, this.max);
return Math.round(clamped / this._step) * this._step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
const stepped = Math.round(value / this._step) * this._step;
return conditionalClamp(stepped, this.min, this.max);
}
private get _step() {
@@ -67,10 +69,7 @@ export class HaControlNumberButton extends LitElement {
private get _tenPercentStep() {
if (this.max == null || this.min == null) return this._step;
const range = this.max - this.min / 10;
if (range <= this._step) return this._step;
return Math.max(range / 10);
return Math.max((this.max - this.min) / 10, this._step);
}
private _handlePlusButton() {
+5 -7
View File
@@ -115,7 +115,9 @@ export class HaControlSlider extends LitElement {
}
steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
return this.boundedValue(Math.round(value / this.step) * this.step);
}
private _displayedValue(value: number) {
@@ -254,13 +256,9 @@ export class HaControlSlider extends LitElement {
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
} else if (e.code === "PageDown") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
} else {
const isRtl = mainWindow.document.dir === "rtl";
let multiplier = 1;
@@ -131,6 +131,7 @@ export class HaNumberSelector extends LitElement {
.hint=${isBox ? this.helper : undefined}
.disabled=${this.disabled}
.required=${this.required}
.validationMessage=${this.selector.number?.validation_message}
type="number"
autoValidate
.withoutSpinButtons=${!isBox}
+27 -3
View File
@@ -31,6 +31,8 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -148,7 +150,12 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
return;
}
@@ -156,7 +163,12 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
return;
}
@@ -187,12 +199,15 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -253,6 +268,15 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
+3
View File
@@ -397,6 +397,9 @@ export interface NumberSelector {
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
// Shown instead of the browser's native message when the value fails
// min/max/step constraint validation.
validation_message?: string;
} | null;
}
+220 -81
View File
@@ -1,7 +1,20 @@
import { mdiClose } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -10,7 +23,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
// Unique ID for updating or closing a specific toast without flickering.
id?: string;
message:
| string
@@ -34,105 +47,150 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _parameters?: ShowToastParams;
@state() private _notifications: Notification[] = [];
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@query(".stack") private _stack?: HTMLDivElement;
private _showDialogId = 0;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
public async showDialog(parameters: ShowToastParams) {
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
if (parameters.duration === 0) {
this._parameters = undefined;
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
return;
}
this._parameters = parameters;
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
await this.updateComplete;
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
this._showStack();
this._getToast(key)?.show();
}
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
if (ev.detail.reason === "dismiss") {
this._parameters?.dismiss?.();
this._getNotification(key)?.parameters.dismiss?.();
}
this._parameters = undefined;
this._removeNotification(key);
}
protected render() {
if (!this._parameters) {
if (!this._notifications.length) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -141,6 +199,7 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -155,19 +214,99 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
}
private _dismissClicked() {
this._toast?.hide("dismiss");
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || !this._stack) {
return;
}
// Top-layer order is order of entry — re-enter so we paint above any
// dialog backdrop opened since the stack was first shown.
if (this._stack.matches(":popover-open")) {
this._stack.hidePopover();
}
this._stack.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
@@ -455,7 +456,7 @@ class DialogAreaDetail
return deviceReg && deviceReg.area_id === areaId;
};
private _nameChanged(ev: InputEvent) {
private _nameChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
@@ -479,7 +480,9 @@ class DialogAreaDetail
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
private _pictureChanged(
ev: ValueChangedEvent<string | null> & HASSDomTargetEvent<HaPictureUpload>
) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
@@ -490,7 +493,9 @@ class DialogAreaDetail
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
private _sensorChanged(
ev: ValueChangedEvent<string> & HASSDomTargetEvent<HaEntityPicker>
): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-alert";
@@ -336,13 +337,13 @@ class DialogFloorDetail extends DirtyStateProviderMixin<FloorFormState>()(
this._updateDirtyState(this._currentState());
}
private _nameChanged(ev: InputEvent) {
private _nameChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _levelChanged(ev: InputEvent) {
private _levelChanged(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
this._error = undefined;
this._level =
(ev.target as HaInput).value === ""
@@ -35,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>
) => {
@@ -237,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"
),
@@ -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;
@@ -68,6 +68,7 @@ export class CloudAccount extends SubscribeMixin(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}>
@@ -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", {
+1
View File
@@ -106,6 +106,7 @@ class HaPanelConfig extends HassRouterPage {
integrations: {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),
waitForReady: true,
},
labels: {
tag: "ha-config-labels",
@@ -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";
@@ -54,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";
@@ -168,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(
@@ -388,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();
}
@@ -415,6 +435,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
if (this.configEntries && this.configEntriesInProgress) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
const activeElement = deepActiveElement();
if (
@@ -11,6 +11,7 @@ import {
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import "../../../layouts/hass-loading-screen";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@@ -70,6 +71,11 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
private _loadTranslationsPromise?: Promise<LocalizeFunc>;
public constructor() {
super();
new ChildPanelReady(this);
}
public hassSubscribe() {
return [
subscribeConfigEntries(
@@ -3,6 +3,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
@@ -451,7 +452,9 @@ export class BluetoothAdapterInfoPage extends LitElement {
return this._formatModeLabel(scannerState.current_mode);
}
private async _handleEnable(ev: Event) {
private async _handleEnable(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entry: ConfigEntry }>
) {
const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry };
const entryId = button.entry.entry_id;
try {
@@ -474,7 +477,9 @@ export class BluetoothAdapterInfoPage extends LitElement {
}
}
private _openOptionFlow(ev: Event) {
private _openOptionFlow(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entry: ConfigEntry }>
) {
ev.preventDefault();
ev.stopPropagation();
const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry };
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, 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/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
@@ -211,7 +212,9 @@ class DialogMatterLockManage extends LitElement {
`;
}
private _handleUserClick(ev: Event): void {
private _handleUserClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { user: MatterLockUser }>
): void {
// Ignore clicks that originated from the delete button
const path = ev.composedPath();
if (path.some((el) => (el as HTMLElement).tagName === "HA-ICON-BUTTON")) {
@@ -221,7 +224,9 @@ class DialogMatterLockManage extends LitElement {
this._editUser(user);
}
private _handleDeleteUserClick(ev: Event): void {
private _handleDeleteUserClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { user: MatterLockUser }>
): void {
ev.preventDefault();
ev.stopPropagation();
const user = (ev.currentTarget as any).user as MatterLockUser;
@@ -11,10 +11,7 @@ import { addGroup, fetchGroupableDevices } from "../../../../../data/zha";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../../../types";
import "./zha-device-endpoint-list";
import type {
DeviceEndpointSelectionChangedEvent,
ZHADeviceEndpointList,
} from "./zha-device-endpoint-list";
import type { ZHADeviceEndpointList } from "./zha-device-endpoint-list";
@customElement("zha-add-group-page")
export class ZHAAddGroupPage extends LitElement {
@@ -131,7 +128,7 @@ export class ZHAAddGroupPage extends LitElement {
}
private _handleAddSelectionChanged(
ev: HASSDomEvent<DeviceEndpointSelectionChangedEvent>
ev: HASSDomEvent<HASSDomEvents["selection-changed"]>
): void {
this._selectedDevicesToAdd = ev.detail.value;
}
@@ -4,10 +4,15 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
@@ -269,11 +274,16 @@ export class ZHADeviceEndpointList extends LitElement {
.join(" · ");
}
private _handleFilterChanged(ev: Event): void {
this._filter = (ev.currentTarget as HTMLInputElement).value;
private _handleFilterChanged(
ev: HASSDomCurrentTargetEvent<HaInputSearch>
): void {
this._filter = ev.currentTarget.value ?? "";
}
private _handleItemSelected(ev: CustomEvent<number>): void {
private _handleItemSelected(
ev: HASSDomEvent<HASSDomEvents["ha-list-item-selected"]> &
HASSDomCurrentTargetEvent<HaListSelectable>
): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDeviceIds = this._selectedDeviceIds;
@@ -290,7 +300,10 @@ export class ZHADeviceEndpointList extends LitElement {
}
}
private _handleItemDeselected(ev: CustomEvent<number>): void {
private _handleItemDeselected(
ev: HASSDomEvent<HASSDomEvents["ha-list-item-deselected"]> &
HASSDomCurrentTargetEvent<HaListSelectable>
): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDeviceIds = this._selectedDeviceIds;
@@ -19,10 +19,7 @@ import "../../../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../../../types";
import { formatAsPaddedHex } from "./functions";
import "./zha-device-endpoint-list";
import type {
DeviceEndpointSelectionChangedEvent,
ZHADeviceEndpointList,
} from "./zha-device-endpoint-list";
import type { ZHADeviceEndpointList } from "./zha-device-endpoint-list";
import { showZHAAddGroupMembersDialog } from "./show-dialog-zha-add-group-members";
@customElement("zha-group-page")
@@ -202,7 +199,7 @@ export class ZHAGroupPage extends LitElement {
}
private _handleRemoveSelectionChanged(
ev: HASSDomEvent<DeviceEndpointSelectionChangedEvent>
ev: HASSDomEvent<HASSDomEvents["selection-changed"]>
): void {
this._selectedDevicesToRemove = ev.detail.value;
}
@@ -1,13 +1,16 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import "../../../../../components/ha-switch";
import type { HaSwitch } from "../../../../../components/ha-switch";
import type { ZHAConfiguration } from "../../../../../data/zha";
import {
fetchZHAConfiguration,
@@ -18,6 +21,8 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
const PREDEFINED_TIMEOUTS = [1800, 3600, 7200, 21600, 43200, 86400];
type ZHASwitchChangeEvent = HASSDomCurrentTargetEvent<HaSwitch>;
type ZHAInputChangeEvent = HASSDomCurrentTargetEvent<HaInput>;
@customElement("zha-options-page")
class ZHAOptionsPage extends LitElement {
@@ -345,53 +350,54 @@ class ZHAOptionsPage extends LitElement {
`;
}
private _enableIdentifyOnJoinChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.enable_identify_on_join = checked;
private _enableIdentifyOnJoinChanged(ev: ZHASwitchChangeEvent): void {
this._configuration!.data.zha_options.enable_identify_on_join =
ev.currentTarget.checked;
this.requestUpdate();
}
private _enhancedLightTransitionChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.enhanced_light_transition = checked;
private _enhancedLightTransitionChanged(ev: ZHASwitchChangeEvent): void {
this._configuration!.data.zha_options.enhanced_light_transition =
ev.currentTarget.checked;
this.requestUpdate();
}
private _lightTransitioningFlagChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.light_transitioning_flag = checked;
private _lightTransitioningFlagChanged(ev: ZHASwitchChangeEvent): void {
this._configuration!.data.zha_options.light_transitioning_flag =
ev.currentTarget.checked;
this.requestUpdate();
}
private _groupMembersAssumeStateChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.group_members_assume_state = checked;
private _groupMembersAssumeStateChanged(ev: ZHASwitchChangeEvent): void {
this._configuration!.data.zha_options.group_members_assume_state =
ev.currentTarget.checked;
this.requestUpdate();
}
private _enableMainsStartupPollingChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
private _enableMainsStartupPollingChanged(ev: ZHASwitchChangeEvent): void {
this._configuration!.data.zha_options.enable_mains_startup_polling =
checked;
ev.currentTarget.checked;
this.requestUpdate();
}
private _defaultLightTransitionChanged(ev: Event): void {
const value = Number((ev.target as HTMLInputElement).value);
this._configuration!.data.zha_options.default_light_transition = value;
private _defaultLightTransitionChanged(ev: ZHAInputChangeEvent): void {
this._configuration!.data.zha_options.default_light_transition = Number(
ev.currentTarget.value
);
this.requestUpdate();
}
private _customMainsSecondsChanged(ev: Event): void {
const seconds = Number((ev.target as HTMLInputElement).value);
this._configuration!.data.zha_options.consider_unavailable_mains = seconds;
private _customMainsSecondsChanged(ev: ZHAInputChangeEvent): void {
this._configuration!.data.zha_options.consider_unavailable_mains = Number(
ev.currentTarget.value
);
this.requestUpdate();
}
private _customBatterySecondsChanged(ev: Event): void {
const seconds = Number((ev.target as HTMLInputElement).value);
this._configuration!.data.zha_options.consider_unavailable_battery =
seconds;
private _customBatterySecondsChanged(ev: ZHAInputChangeEvent): void {
this._configuration!.data.zha_options.consider_unavailable_battery = Number(
ev.currentTarget.value
);
this.requestUpdate();
}
@@ -419,7 +425,15 @@ class ZHAOptionsPage extends LitElement {
this.requestUpdate();
}
private async _updateConfiguration(ev: Event): Promise<void> {
private async _updateConfiguration(
ev: HASSDomCurrentTargetEvent<
HTMLElement & {
progress: boolean;
actionSuccess: () => void;
actionError: () => void;
}
>
): Promise<void> {
const button = ev.currentTarget as HTMLElement & {
progress: boolean;
actionSuccess: () => void;
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, 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 type { LocalizeKeys } from "../../../../../common/translations/localize";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
@@ -478,7 +479,9 @@ class DialogZwaveCredentialUserEdit extends DirtyStateProviderMixin<CredentialFo
}
}
private _handleCredentialTypeChanged(ev: Event): void {
private _handleCredentialTypeChanged(
ev: HASSDomCurrentTargetEvent<HaRadioGroup>
): void {
this._credentialType = (ev.currentTarget as HaRadioGroup)
.value as ZwaveCredentialType;
// Switching types invalidates any data tied to the previous type's
@@ -2,11 +2,13 @@ import { mdiCloseCircle } from "@mdi/js";
import { LitElement, css, html, 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/ha-button";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import {
getZwaveNodeRawConfigParameter,
setZwaveNodeRawConfigParameter,
@@ -129,9 +131,9 @@ class ZWaveJSCustomParam extends LitElement {
return parsed;
}
private _customParamNumberChanged(ev: Event) {
private _customParamNumberChanged(ev: HASSDomCurrentTargetEvent<HaInput>) {
this._customParamNumber = this._tryParseNumber(
(ev.target as HTMLInputElement).value
ev.currentTarget.value ?? ""
);
}
@@ -139,8 +141,8 @@ class ZWaveJSCustomParam extends LitElement {
this._valueSize = this._tryParseNumber(ev.detail.value) ?? 1;
}
private _customValueChanged(ev: Event) {
this._value = this._tryParseNumber((ev.target as HTMLInputElement).value);
private _customValueChanged(ev: HASSDomCurrentTargetEvent<HaInput>) {
this._value = this._tryParseNumber(ev.currentTarget.value ?? "");
}
private _customValueFormatChanged(ev: HaSelectSelectEvent) {
+4 -1
View File
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/buttons/ha-call-service-button";
import "../../../components/ha-alert";
@@ -283,7 +284,9 @@ export class SystemLogCard extends LitElement {
fileDownload(signedUrl.path, logFileName);
}
private _openLog(ev: Event): void {
private _openLog(
ev: HASSDomCurrentTargetEvent<HTMLElement & { logItem: LoggedError }>
): void {
const item = (ev.currentTarget as any).logItem;
showSystemLogDetailDialog(this, { item });
}
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mainWindow } from "../../../common/dom/get_main_window";
import { HOSTNAME_PATTERN } from "../../../common/string/is_hostname";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
@@ -38,7 +39,16 @@ const SCHEMA = memoizeOne(
{
name: "server_port",
required: true,
selector: { number: { min: 1, max: 65535, mode: "box" } },
selector: {
number: {
min: 1,
max: 65535,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_port"
),
},
},
},
{
name: "ssl",
@@ -118,7 +128,16 @@ const SCHEMA = memoizeOne(
{
name: "login_attempts_threshold",
required: true,
selector: { number: { min: -1, max: 1000, mode: "box" } },
selector: {
number: {
min: -1,
max: 1000,
mode: "box",
validation_message: localize(
"ui.panel.config.network.http.invalid_login_attempts_threshold"
),
},
},
},
],
},
@@ -133,7 +152,7 @@ const SCHEMA = memoizeOne(
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_PATTERN,
pattern: `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`,
validation_message: localize(
"ui.panel.config.network.http.invalid_host"
),
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { isIPAddress } from "../../../common/string/is_ip_address";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -154,14 +155,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.url.description")}
</div>
<h4>
${this.hass.localize("ui.panel.config.url.external_url_label")}
</h4>
${
hasCloud
? html`
<h4>
${this.hass.localize(
"ui.panel.config.url.external_url_label"
)}
</h4>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
@@ -363,12 +362,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
);
}
private _toggleCloud(ev: Event) {
private _toggleCloud(ev: HASSDomCurrentTargetEvent<HaSwitch>) {
this._cloudChecked = (ev.currentTarget as HaSwitch).checked;
this._showCustomExternalUrl = !this._cloudChecked;
}
private _toggleInternalAutomatic(ev: Event) {
private _toggleInternalAutomatic(ev: HASSDomCurrentTargetEvent<HaSwitch>) {
this._showCustomInternalUrl = !(ev.currentTarget as HaSwitch).checked;
}
@@ -2,6 +2,10 @@ import { mdiDeleteOutline, mdiMenuDown, mdiPlus, mdiWifi } from "@mdi/js";
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
@@ -630,7 +634,9 @@ export class HassioNetwork extends LitElement {
this._interface = { ...this._interfaces[this._curTabIndex] };
}
private _handleRadioValueChanged(ev: Event): void {
private _handleRadioValueChanged(
ev: HASSDomCurrentTargetEvent<HaRadioGroup & { version: "ipv4" | "ipv6" }>
): void {
const source = ev.currentTarget as HaRadioGroup;
const value = source.value as "disabled" | "auto" | "static";
const version = (source as any).version as "ipv4" | "ipv6";
@@ -645,7 +651,9 @@ export class HassioNetwork extends LitElement {
this.requestUpdate("_interface");
}
private _handleRadioValueChangedAp(ev: Event): void {
private _handleRadioValueChangedAp(
ev: HASSDomCurrentTargetEvent<HaRadioGroup>
): void {
const source = ev.currentTarget as HaRadioGroup;
const value = source.value as "open" | "wep" | "wpa-psk";
this._wifiConfiguration!.auth = value;
@@ -653,7 +661,11 @@ export class HassioNetwork extends LitElement {
this.requestUpdate("_wifiConfiguration");
}
private _handleInputValueChanged(ev: Event): void {
private _handleInputValueChanged(
ev: HASSDomTargetEvent<
HaInput & { version: "ipv4" | "ipv6"; index: number }
>
): void {
const source = ev.target as HaInput;
const value = source.value;
const version = (ev.target as any).version as "ipv4" | "ipv6";
@@ -691,7 +703,7 @@ export class HassioNetwork extends LitElement {
}
}
private _handleInputValueChangedWifi(ev: Event): void {
private _handleInputValueChangedWifi(ev: HASSDomTargetEvent<HaInput>): void {
const source = ev.target as HaInput;
const value = source.value;
const id = source.id;
@@ -708,7 +720,9 @@ export class HassioNetwork extends LitElement {
this._wifiConfiguration![id] = value;
}
private _addAddress(ev: Event): void {
private _addAddress(
ev: HASSDomTargetEvent<HTMLElement & { version: "ipv4" | "ipv6" }>
): void {
const version = (ev.target as any).version as "ipv4" | "ipv6";
this._interface![version]!.address!.push(
version === "ipv4" ? "0.0.0.0/24" : "::/64"
@@ -717,7 +731,11 @@ export class HassioNetwork extends LitElement {
this.requestUpdate("_interface");
}
private _removeAddress(ev: Event): void {
private _removeAddress(
ev: HASSDomTargetEvent<
HTMLElement & { index: number; version: "ipv4" | "ipv6" }
>
): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
@@ -752,7 +770,11 @@ export class HassioNetwork extends LitElement {
this.requestUpdate("_interface");
}
private _removeNameserver(ev: Event): void {
private _removeNameserver(
ev: HASSDomTargetEvent<
HTMLElement & { index: number; version: "ipv4" | "ipv6" }
>
): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
@@ -33,7 +33,10 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showEditorToast } from "../automation/editor-toast";
import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import { ManualEditorMixin } from "../automation/ha-manual-editor-mixin";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "../automation/ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "../automation/styles";
import "./ha-script-fields";
@@ -339,6 +342,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.script.editor.paste_toast_message"
),
@@ -11,6 +11,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityEntryName } from "../../../../common/entity/compute_entity_name";
@@ -260,7 +261,9 @@ class HaPanelDevStateRenderer extends LitElement {
return output;
}
private _copyEntity = async (ev: Event) => {
private _copyEntity = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: HassEntity }>
) => {
ev.preventDefault();
const entity = (ev.currentTarget as HTMLElement & { entity: HassEntity })
.entity;
@@ -270,14 +273,18 @@ class HaPanelDevStateRenderer extends LitElement {
});
};
private _entityMoreInfo(ev: Event) {
private _entityMoreInfo(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: HassEntity }>
) {
ev.preventDefault();
const entity = (ev.currentTarget as HTMLElement & { entity: HassEntity })
.entity;
fireEvent(this, "hass-more-info", { entityId: entity.entity_id });
}
private _entitySelected(ev: Event) {
private _entitySelected(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: HassEntity }>
) {
ev.preventDefault();
const entity = (ev.currentTarget as HTMLElement & { entity: HassEntity })
.entity;
@@ -16,7 +16,11 @@ import { consume, type ContextType } from "@lit/context";
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import {
@@ -596,7 +600,9 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
`;
}
private _handleSearchChange(ev: InputEvent) {
private _handleSearchChange(
ev: InputEvent & HASSDomTargetEvent<HaInputSearch>
) {
if (this.filter === (ev.target as HaInputSearch).value) {
return;
}
@@ -706,7 +712,9 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
);
}
private _showStatisticsAdjustSumDialog(ev: Event) {
private _showStatisticsAdjustSumDialog(
ev: HASSDomCurrentTargetEvent<HTMLElement & { statistic: StatisticData }>
) {
ev.stopPropagation();
showStatisticsAdjustSumDialog(this, {
statistic: (
@@ -782,7 +790,11 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
});
};
private _fixIssue = async (ev: Event) => {
private _fixIssue = async (
ev: HASSDomCurrentTargetEvent<
HTMLElement & { data: StatisticsValidationResult[] }
>
) => {
const issues = (
ev.currentTarget as HTMLElement & { data: StatisticsValidationResult[] }
).data.sort(
@@ -9,6 +9,7 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomTargetEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
@@ -419,7 +420,7 @@ ${
`;
}
private _splitRepositioned(ev: Event) {
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
this._splitPosition = (ev.target as HaSplitPanel).position;
this._storeSplitPosition();
}
@@ -6,8 +6,12 @@ import { customElement, property, state } from "lit/decorators";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-control-slider";
import type { ClimateEntity } from "../../../data/climate";
import { ClimateEntityFeature } from "../../../data/climate";
import {
apiContext,
formattersContext,
@@ -31,7 +35,11 @@ import type {
const supportsTargetHumidityCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "humidifier";
return (
domain === "humidifier" ||
(domain === "climate" &&
supportsFeature(stateObj, ClimateEntityFeature.TARGET_HUMIDITY))
);
};
export const supportsTargetHumidityCardFeature = (
@@ -54,7 +62,7 @@ class HuiTargetHumidityCardFeature
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: HumidifierEntity;
private _stateObj?: HumidifierEntity | ClimateEntity;
@state()
@consume({ context: apiContext, subscribe: true })
@@ -95,7 +103,9 @@ class HuiTargetHumidityCardFeature
}
}
private _step = 1;
private get _step() {
return this._stateObj!.attributes.target_humidity_step ?? 1;
}
private get _min() {
return this._stateObj!.attributes.min_humidity ?? 0;
@@ -113,7 +123,8 @@ class HuiTargetHumidityCardFeature
}
private _callService() {
this._api.callService("humidifier", "set_humidity", {
const domain = computeStateDomain(this._stateObj!);
this._api.callService(domain, "set_humidity", {
entity_id: this._stateObj!.entity_id,
humidity: this._targetHumidity,
});
@@ -46,6 +46,7 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
["climate-swing-modes"],
["climate-swing-horizontal-modes"],
["target-temperature"],
["target-humidity"],
],
media_player: [
TILE_VARIANT,
@@ -149,6 +149,7 @@ export class HuiEnergyDevicesDetailGraphCard
this._yAxisFractionDigits,
this._legendData
)}
.expandLegend=${this._config.expand_legend}
click-label-for-more-info
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -192,6 +192,7 @@ export class HuiEnergyDevicesGraphCard
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]}
.expandLegend=${this._config.expand_legend}
click-label-for-more-info
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@@ -177,6 +177,7 @@ export class HuiEnergyUsageGraphCard
this._legendData
)}
chart-type="bar"
.expandLegend=${this._config.expand_legend}
></ha-chart-base>
${
!this._chartData.some((dataset) => dataset.data!.length)
@@ -121,6 +121,7 @@ export class HuiPowerSourcesGraphCard
this._legendData,
this._yAxisFractionDigits
)}
.expandLegend=${this._config.expand_legend}
></ha-chart-base>
${
!this._chartData.some((dataset) => dataset.data!.length)
+4
View File
@@ -189,6 +189,7 @@ export interface EnergyDistributionCardConfig extends EnergyCardConfig {
export interface EnergyUsageGraphCardConfig extends EnergyCardConfig {
type: "energy-usage-graph";
show_legend?: boolean;
expand_legend?: boolean;
}
export interface EnergySolarGraphCardConfig extends EnergyCardConfig {
@@ -208,11 +209,13 @@ export interface EnergyDevicesGraphCardConfig extends EnergyCardConfig {
max_devices?: number;
hide_compound_stats?: boolean;
modes?: ("bar" | "pie")[];
expand_legend?: boolean;
}
export interface EnergyDevicesDetailGraphCardConfig extends EnergyCardConfig {
type: "energy-devices-detail-graph";
max_devices?: number;
expand_legend?: boolean;
}
export interface EnergySourcesTableCardConfig extends EnergyCardConfig {
@@ -244,6 +247,7 @@ export interface EnergyCarbonGaugeCardConfig extends EnergyCardConfig {
export interface PowerSourcesGraphCardConfig extends EnergyCardConfig {
type: "power-sources-graph";
show_legend?: boolean;
expand_legend?: boolean;
}
export interface EnergySankeyCardConfig extends EnergyCardSankeyConfig {
@@ -38,6 +38,7 @@ const cardConfigStruct = assign(
max_devices: optional(number()),
modes: optional(array(union([literal("bar"), literal("pie")]))),
hide_compound_stats: optional(boolean()),
expand_legend: optional(boolean()),
})
);
@@ -91,6 +92,11 @@ export class HuiEnergyDevicesCardEditor
visible: { field: "type", value: "energy-devices-graph" },
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
{
name: "max_devices",
required: false,
@@ -40,6 +40,19 @@ const SCHEMA: HaFormSchema[] = [
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
visible: [
{
field: "type",
operator: "in",
value: ["power-sources-graph", "energy-usage-graph"],
},
{ field: "show_legend", operator: "not_eq", value: false },
],
required: false,
selector: { boolean: {} },
},
{
name: "link_dashboard",
visible: { field: "type", value: "energy-distribution" },
@@ -73,6 +86,7 @@ const cardConfigStruct = assign(
title: optional(string()),
collection_key: optional(string()),
show_legend: optional(boolean()),
expand_legend: optional(boolean()),
link_dashboard: optional(boolean()),
})
);
@@ -587,10 +587,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
...(sidebarSection && {
sidebar: {
sections: [sidebarSection],
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
sidebar_label: hass.localize(
"ui.panel.lovelace.strategy.home.summaries"
),
visibility: [LARGE_SCREEN_CONDITION],
},
}),
+19 -5
View File
@@ -176,6 +176,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const editMode = this.lovelace.editMode;
const hasSidebar =
this._config?.sidebar && (this._sidebarVisible || editMode);
// The narrow screen tab switcher is opt-in: a sidebar enables it by
// labelling both tabs, otherwise the sidebar stacks below the content.
const useMobileTabs = Boolean(
this.narrow &&
hasSidebar &&
this._config?.sidebar?.content_label &&
this._config?.sidebar?.sidebar_label
);
const totalSectionCount =
this._sectionColumnCount + (editMode ? 1 : 0) + (hasSidebar ? 1 : 0);
@@ -184,7 +192,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
Math.min(this._maxColumns, totalSectionCount),
1
);
// On mobile with sidebar, use full width for whichever view is active
// On mobile with sidebar, content and sidebar both take full width
// (stacked, or switched between via the tabs when opted in)
const contentColumnCount =
hasSidebar && !this.narrow ? Math.max(1, columnCount - 1) : columnCount;
@@ -199,6 +208,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
"top-margin": Boolean(this._config?.top_margin),
"has-sidebar": Boolean(hasSidebar),
narrow: this.narrow,
"mobile-tabs-enabled": useMobileTabs,
})}"
style=${styleMap({
"--column-count": columnCount,
@@ -213,7 +223,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.config=${this._config?.header}
></hui-view-header>
${
this.narrow && hasSidebar
useMobileTabs
? html`
<div class="mobile-tabs">
<ha-control-select
@@ -246,7 +256,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<div
class="content ${classMap({
dense: Boolean(this._config?.dense_section_placement),
"mobile-hidden": this.narrow && this._sidebarTabActive,
"mobile-hidden": useMobileTabs && this._sidebarTabActive,
})}"
>
${repeat(
@@ -334,7 +344,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<hui-view-sidebar
class=${classMap({
"mobile-hidden":
!hasSidebar || (this.narrow && !this._sidebarTabActive),
!hasSidebar ||
(useMobileTabs && !this._sidebarTabActive),
})}
.hass=${this.hass}
.badges=${this.badges}
@@ -613,6 +624,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.wrapper.narrow hui-view-sidebar {
grid-column: 1 / -1;
}
.wrapper.narrow.mobile-tabs-enabled hui-view-sidebar {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
@@ -663,7 +677,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
grid-column: 1 / -1;
}
.wrapper.narrow.has-sidebar .content {
.wrapper.narrow.mobile-tabs-enabled .content {
padding-bottom: calc(
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
);
+9 -1
View File
@@ -13,6 +13,9 @@ import { showToast } from "../util/toast";
import type { HassBaseEl } from "./hass-base-mixin";
import { navigate } from "../common/navigate";
const CONNECTION_LOST_TOAST_ID = "connection-lost";
const SERVER_STARTUP_TOAST_ID = "server-startup";
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
private _subscribedBootstrapIntegrations?: Promise<UnsubscribeFunc>;
@@ -34,6 +37,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (oldHass?.config?.state !== this.hass!.config.state) {
if (this.hass!.config.state === STATE_NOT_RUNNING) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.starting") ||
"Home Assistant is starting. Not everything will be available until it is finished.",
@@ -57,6 +61,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
) {
this._unsubscribeBootstrapIntegrations();
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.started"),
duration: 5000,
});
@@ -95,6 +100,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
return;
}
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: "",
duration: 0,
});
@@ -106,6 +112,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._disconnectedTimeout = window.setTimeout(() => {
this._disconnectedTimeout = undefined;
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.connection_lost"),
duration: -1,
dismissable: false,
@@ -120,6 +127,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (Object.keys(message).length === 0) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.wrapping_up_startup") ||
`Wrapping up startup. Not everything will be available until it is finished.`,
@@ -142,7 +150,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)[0][0];
showToast(this, {
id: "integration_starting",
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.integration_starting", {
integration: domainToName(this.hass!.localize, integration),
+5 -4
View File
@@ -8766,8 +8766,10 @@
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"server_host_warning": "Leave this empty to listen on all interfaces. If you set specific addresses, Home Assistant is only reachable through those — binding it only to an address that other devices can't reach (for example localhost) will make it unreachable from those devices.",
"invalid_host": "Enter a valid IP address.",
"invalid_host": "Enter a valid IP address or hostname.",
"invalid_network": "Enter a valid IP address or network.",
"invalid_port": "Enter a port number between 1 and 65535.",
"invalid_login_attempts_threshold": "Enter a number between -1 and 1000.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
@@ -8806,7 +8808,7 @@
},
"helpers": {
"server_port": "The port Home Assistant listens on. Default is {port}.",
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
"server_host": "IP addresses or hostnames to bind to. Leave empty to listen on all interfaces.",
"ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).",
"ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).",
"ssl_peer_certificate": "Absolute path to a client certificate Home Assistant should require for secure connections.",
@@ -8999,8 +9001,6 @@
"others": "Others",
"scenes": "Scenes",
"automations": "Automations",
"for_you": "For you",
"home": "Home",
"favorites": "Favorites",
"welcome_title": "No devices here yet",
"welcome_content": "Add lights, switches, sensors, or other smart home devices to get started.",
@@ -10095,6 +10095,7 @@
"double_tap_action": "Double tap behavior",
"entities": "Entities",
"entity": "Entity",
"expand_legend": "Expand legend",
"fit_mode": "Fit mode",
"fit_mode_options": {
"contain": "Scaled (Contain)",
+96
View File
@@ -0,0 +1,96 @@
/**
* @vitest-environment node
*/
import { Buffer } from "node:buffer";
import { Readable } from "node:stream";
import { promisify } from "node:util";
import { brotliCompress, constants } from "node:zlib";
import { describe, expect, it } from "vitest";
import brotli from "../../build-scripts/brotli.mjs";
import { file, filler, run as runStream } from "./vinyl-stub.js";
// What build-scripts/gulp/compress.js asks for.
const PARAMS = {
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
};
const run = (files, options = { skipLarger: true, params: PARAMS }) =>
runStream(brotli(options), files);
const compressInProcess = promisify(brotliCompress);
describe("brotli", () => {
it("appends .br and matches zlib byte for byte", async () => {
const contents = filler(4096);
const [result] = await run([file("/out/app.js", contents)]);
expect(result.path).toBe("/out/app.js.br");
expect(result.contents).toEqual(
await compressInProcess(contents, { params: PARAMS })
);
});
it("returns contents as a Buffer, which vinyl requires", async () => {
const [result] = await run([file("/out/app.js", filler(1024))]);
expect(Buffer.isBuffer(result.contents)).toBe(true);
});
it("appends to the path rather than replacing the extension", async () => {
const [result] = await run([file("/out/nested/chunk.min.js", filler(500))]);
expect(result.path).toBe("/out/nested/chunk.min.js.br");
});
it("drops files that compression grows when skipLarger is set", async () => {
expect(await run([file("/out/tiny.js", filler(1))])).toEqual([]);
});
it("keeps files that compression grows when skipLarger is not set", async () => {
const [result] = await run([file("/out/tiny.js", filler(1))], {
params: PARAMS,
});
expect(result.path).toBe("/out/tiny.js.br");
});
it("passes null files through", async () => {
const [result] = await run([file("/out/adirectory", null)]);
expect(result.path).toBe("/out/adirectory");
expect(result.contents).toBeNull();
});
it("buffers stream-mode contents", async () => {
const contents = filler(600);
const [result] = await run([
file("/out/streamed.js", Readable.from([contents])),
]);
expect(result.path).toBe("/out/streamed.js.br");
expect(result.contents).toEqual(
await compressInProcess(contents, { params: PARAMS })
);
});
it("handles more files than it keeps in flight", async () => {
const count = 40;
const files = Array.from({ length: count }, (_, index) =>
file(`/out/chunk${index}.js`, filler(200 + index))
);
const results = await run(files);
expect(results).toHaveLength(count);
expect(new Set(results.map((result) => result.path)).size).toBe(count);
await Promise.all(
results.map(async (result) => {
const index = Number(result.path.match(/chunk(\d+)/)[1]);
expect(result.contents).toEqual(
await compressInProcess(filler(200 + index), { params: PARAMS })
);
})
);
});
});
@@ -0,0 +1,104 @@
/**
* @vitest-environment node
*/
import { setImmediate } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { ParallelTransform } from "../../build-scripts/parallel-transform.mjs";
import { run } from "./vinyl-stub.js";
const defer = () => {
const handle = {};
handle.promise = new Promise((resolve, reject) => {
handle.resolve = resolve;
handle.reject = reject;
});
return handle;
};
describe("ParallelTransform", () => {
it("keeps `limit` handlers in flight and no more", async () => {
const pending = [];
let active = 0;
let peak = 0;
const stream = new ParallelTransform(3, () => {
active += 1;
peak = Math.max(peak, active);
const handle = defer();
pending.push(handle);
return handle.promise.then((result) => {
active -= 1;
return result;
});
});
const items = Array.from({ length: 10 }, (_, index) => `item${index}`);
const done = run(stream, items);
await setImmediate();
expect(pending).toHaveLength(3);
for (let index = 0; index < items.length; index += 1) {
pending[index].resolve(items[index]);
// eslint-disable-next-line no-await-in-loop -- releasing one job at a time is the point
await setImmediate();
}
expect(await done).toEqual(items);
expect(peak).toBe(3);
});
it("emits in completion order rather than input order", async () => {
const pending = new Map();
const stream = new ParallelTransform(3, (item) => {
const handle = defer();
pending.set(item, handle);
return handle.promise;
});
const done = run(stream, ["a", "b", "c"]);
await setImmediate();
["c", "a", "b"].forEach((item) => pending.get(item).resolve(item));
expect(await done).toEqual(["c", "a", "b"]);
});
it("drops results the handler resolves to nothing for", async () => {
const stream = new ParallelTransform(2, async (item) =>
item === "skip" ? undefined : item
);
expect(await run(stream, ["a", "skip", "b"])).toEqual(["a", "b"]);
});
it("fails the stream when a handler rejects", async () => {
const stream = new ParallelTransform(2, async (item) => {
if (item === "bad") {
throw new Error("boom");
}
return item;
});
await expect(run(stream, ["a", "bad", "b"])).rejects.toThrow("boom");
});
it("does not finish until in-flight work completes", async () => {
const handle = defer();
let ended = false;
const stream = new ParallelTransform(2, () => handle.promise);
const done = run(stream, ["a"]).then((results) => {
ended = true;
return results;
});
await setImmediate();
expect(ended).toBe(false);
handle.resolve("a");
expect(await done).toEqual(["a"]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { Buffer } from "node:buffer";
import { Readable } from "node:stream";
// Stand-in for the vinyl files gulp.src yields in buffer mode.
export const file = (path, contents) => ({
path,
contents,
isNull() {
return this.contents === null;
},
isStream() {
return this.contents instanceof Readable;
},
});
export const filler = (length) => Buffer.alloc(length, "a");
export const run = (stream, files) =>
new Promise((resolve, reject) => {
const out = [];
stream.on("data", (result) => out.push(result));
stream.on("error", reject);
stream.on("end", () => resolve(out));
files.forEach((entry) => stream.write(entry));
stream.end();
});
+2 -23
View File
@@ -7,6 +7,7 @@ import process from "node:process";
import { Readable } from "node:stream";
import gfxZopfli from "@gfx/zopfli";
import { describe, expect, it } from "vitest";
import { file, filler, run as runStream } from "./vinyl-stub.js";
// Keep the pool small; it is created once, on the first factory call.
process.env.ZOPFLI_WORKERS = "2";
@@ -15,28 +16,8 @@ const { default: zopfli } = await import("../../build-scripts/zopfli.mjs");
const THRESHOLD = 150;
// Stand-in for the vinyl files gulp.src yields in buffer mode.
const file = (path, contents) => ({
path,
contents,
isNull() {
return this.contents === null;
},
isStream() {
return this.contents instanceof Readable;
},
});
const run = (files, options = { threshold: THRESHOLD }) =>
new Promise((resolve, reject) => {
const stream = zopfli(options);
const out = [];
stream.on("data", (result) => out.push(result));
stream.on("error", reject);
stream.on("end", () => resolve(out));
files.forEach((entry) => stream.write(entry));
stream.end();
});
runStream(zopfli(options), files);
// What the old in-process plugin did, for byte-for-byte comparison.
const gzipInProcess = (contents) =>
@@ -46,8 +27,6 @@ const gzipInProcess = (contents) =>
);
});
const filler = (length) => Buffer.alloc(length, "a");
describe("zopfli worker pool", () => {
it("appends .gz and matches in-process zopfli byte for byte", async () => {
const contents = filler(4096);
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { HOSTNAME_PATTERN } from "../../../src/common/string/is_hostname";
import { IP_ADDRESS_PATTERN } from "../../../src/common/string/is_ip_address";
const HOSTNAMES = [
"localhost",
"homeassistant.lan",
"home-assistant.local",
"3com.com",
"sub.domain.example.com",
"a".repeat(63),
];
const INVALID = [
"",
"foo bar",
"foo_bar",
"-foo.lan",
"foo-.lan",
"foo..lan",
"héllo.lan",
"host:8123",
"http://host",
"a".repeat(64),
// All-numeric final label: a mistyped IP address, not a hostname.
"123",
"foo.123",
"300.1.1.1",
"999.999.999.999",
];
// The browser anchors a `pattern` attribute as `^(?:…)$` and compiles it with
// the `v` flag. A pattern that fails to compile under `v` is silently ignored,
// disabling validation entirely, so compile it exactly the same way here.
const patternRegexp = (pattern: string): RegExp =>
new RegExp(`^(?:${pattern})$`, "v");
describe("HOSTNAME_PATTERN", () => {
it("compiles as an HTML pattern attribute", () => {
expect(() => patternRegexp(HOSTNAME_PATTERN)).not.toThrow();
});
it("accepts hostnames", () => {
const regexp = patternRegexp(HOSTNAME_PATTERN);
for (const value of HOSTNAMES) {
expect(regexp.test(value), value).toBe(true);
}
});
it("rejects malformed hostnames", () => {
const regexp = patternRegexp(HOSTNAME_PATTERN);
for (const value of INVALID) {
expect(regexp.test(value), value).toBe(false);
}
});
});
// How ha-config-http-form validates its listen addresses.
describe("IP address or hostname pattern", () => {
const composed = `${IP_ADDRESS_PATTERN}|${HOSTNAME_PATTERN}`;
it("compiles as an HTML pattern attribute", () => {
expect(() => patternRegexp(composed)).not.toThrow();
});
it("accepts hostnames and IP addresses", () => {
const regexp = patternRegexp(composed);
for (const value of [
...HOSTNAMES,
"192.168.1.10",
"0.0.0.0",
"255.255.255.255",
"fe80::1",
"::1",
]) {
expect(regexp.test(value), value).toBe(true);
}
});
it("rejects malformed addresses", () => {
const regexp = patternRegexp(composed);
for (const value of INVALID) {
expect(regexp.test(value), value).toBe(false);
}
});
});
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../src/components/ha-control-number-buttons";
import type { HaControlNumberButton } from "../../src/components/ha-control-number-buttons";
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
describe("ha-control-number-buttons step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
let buttons: HaControlNumberButton[] = [];
const mountButtons = async (
props: Partial<HaControlNumberButton>
): Promise<HaControlNumberButton> => {
const el = document.createElement(
"ha-control-number-buttons"
) as HaControlNumberButton;
Object.assign(el, props);
document.body.appendChild(el);
buttons.push(el);
await el.updateComplete;
return el;
};
const stepButton = (el: HaControlNumberButton, label: string) =>
el.shadowRoot!.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
const changedValues = (el: HaControlNumberButton) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
const pressKey = async (el: HaControlNumberButton, code: string) => {
el.shadowRoot!.querySelector('[role="spinbutton"]')!.dispatchEvent(
new KeyboardEvent("keydown", {
code,
bubbles: true,
composed: true,
cancelable: true,
})
);
await el.updateComplete;
};
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
buttons.forEach((el) => el.remove());
buttons = [];
vi.unstubAllGlobals();
});
it("stops at the maximum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([99]);
expect(stepButton(el, "increment").disabled).toBe(true);
});
it("stops at the minimum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 11 });
const values = changedValues(el);
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([1]);
expect(stepButton(el, "decrement").disabled).toBe(true);
});
it("still snaps to the step grid away from the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 44 });
const values = changedValues(el);
stepButton(el, "increment").click();
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([50, 40]);
});
it("steps without bounds when min and max are not set", async () => {
const el = await mountButtons({ step: 10, value: 50 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([60]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 11;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
it("pages by ten percent of the range, but at least one step", async () => {
const wide = await mountButtons({ min: 10, max: 20, step: 1, value: 15 });
const wideValues = changedValues(wide);
await pressKey(wide, "PageUp");
expect(wideValues).toEqual([16]);
// A range narrower than ten steps must still page by a full step.
const narrow = await mountButtons({ min: 0, max: 5, step: 2, value: 0 });
const narrowValues = changedValues(narrow);
await pressKey(narrow, "PageUp");
expect(narrowValues).toEqual([2]);
});
});
+97 -17
View File
@@ -8,6 +8,23 @@ const createSlider = (props: Partial<HaControlSlider>): HaControlSlider => {
return el;
};
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
describe("ha-control-slider value mapping", () => {
afterEach(() => {
document.dir = "ltr";
@@ -54,18 +71,6 @@ describe("ha-control-slider display rounding", () => {
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
const FAN_STEP = 100 / 91;
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
const ariaValueNow = (el: HaControlSlider) =>
el
.shadowRoot!.querySelector('[role="slider"]')!
@@ -74,11 +79,6 @@ describe("ha-control-slider display rounding", () => {
const tooltipText = (el: HaControlSlider) =>
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
it("shows the fractional stepped value by default", async () => {
const el = await mountSlider({ step: FAN_STEP, value: 29 });
expect(tooltipText(el)).toBe("28.57");
@@ -113,3 +113,83 @@ describe("ha-control-slider display rounding", () => {
expect(ariaValueNow(el)).toBe("21.5");
});
});
describe("ha-control-slider step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
const pressKey = async (el: HaControlSlider, code: string) => {
const slider = el.shadowRoot!.querySelector('[role="slider"]')!;
const init = { code, bubbles: true, composed: true, cancelable: true };
slider.dispatchEvent(new KeyboardEvent("keydown", init));
slider.dispatchEvent(new KeyboardEvent("keyup", init));
await el.updateComplete;
};
const changedValues = (el: HaControlSlider) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
it("snaps within the bounds instead of rounding past them", () => {
const el = createSlider(RANGE);
expect(el.steppedValue(99)).toBe(99);
expect(el.steppedValue(1)).toBe(1);
// Values away from the bounds still snap to the step grid.
expect(el.steppedValue(44)).toBe(40);
expect(el.steppedValue(46)).toBe(50);
});
it("stays inside the bounds along the whole pointer path", () => {
// The pointer handlers compose these two, so they have to hold together.
const el = createSlider(RANGE);
expect(el.steppedValue(el.percentageToValue(1))).toBe(99);
expect(el.steppedValue(el.percentageToValue(0))).toBe(1);
});
it("does not overshoot the maximum with a fractional step", () => {
// A 91-speed fan: 91 * (100 / 91) used to land on 100.00000000000001.
const el = createSlider({ min: 0, max: 100, step: 100 / 91 });
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
});
it("shows and announces the bound, not the overshoot", async () => {
const el = await mountSlider({
...RANGE,
value: 99,
tooltipMode: "always",
});
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
"99"
);
expect(
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow")
).toBe("99");
});
it("keeps paging inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "PageUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "PageDown");
expect(values).toEqual([99, 1]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
});
+13
View File
@@ -118,6 +118,19 @@ describe("ha-toast", () => {
).toBe(true);
});
it("shows as part of a stack without becoming a popover", async () => {
const element = await mountToast({ stacked: true });
await showToast(element);
expect(
element.shadowRoot!.querySelector(".toast")!.hasAttribute("popover")
).toBe(false);
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
+82 -81
View File
@@ -4,7 +4,7 @@
* Run with:
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
import {
appSidebar,
appSidebarConfig,
@@ -161,65 +161,100 @@ test.describe("Quick search", () => {
defineRouteSmokeTests(appRouteSmokeGroups);
const assertInitialReadiness = async (
page: Page,
options: {
test("keeps the launch screen until initial panel content renders", async ({
page,
}) => {
const cases: {
name: string;
path: string;
loadingSelector: string;
resolver:
"rejectMediaBrowse" | "resolveCalendarRegistry" | "resolveMediaBrowse";
readySelector: string;
}
) => {
await goToPanel(page, options.path);
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator(options.loadingSelector)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
await page.evaluate((resolver) => window[resolver]?.(), options.resolver);
await expect(page.locator(options.readySelector)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
};
test.describe("Initial readiness", () => {
test("keeps the launch screen until calendar content renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
resolvers: (
| "rejectMediaBrowse"
| "resolveCalendarRegistry"
| "resolveConfigEntries"
| "resolveConfigEntriesInProgress"
| "resolveGeneratedDashboard"
| "resolveLovelaceConfig"
| "resolveMediaBrowse"
)[];
}[] = [
{
name: "calendar",
path: "/?scenario=delayed-calendar#/calendar",
loadingSelector: "ha-panel-calendar ha-spinner",
resolver: "resolveCalendarRegistry",
readySelector: "ha-full-calendar",
});
});
test("keeps the launch screen until media content renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
resolvers: ["resolveCalendarRegistry"],
},
{
name: "media browser",
path: "/?scenario=delayed-media-browse#/media-browser/browser",
loadingSelector: "ha-media-player-browse > ha-spinner",
resolver: "resolveMediaBrowse",
readySelector: "ha-media-player-browse .no-items",
});
});
test("keeps the launch screen until a media error renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
resolvers: ["resolveMediaBrowse"],
},
{
name: "integrations",
path: "/?scenario=delayed-integrations#/config/integrations",
loadingSelector: "ha-config-integrations-dashboard hass-loading-screen",
readySelector: "ha-config-integrations-dashboard hass-tabs-subpage",
resolvers: ["resolveConfigEntries", "resolveConfigEntriesInProgress"],
},
{
name: "media browser error",
path: "/?scenario=delayed-media-browse-error#/media-browser/browser",
loadingSelector: "ha-media-player-browse > ha-spinner",
resolver: "rejectMediaBrowse",
readySelector: "ha-media-player-browse ha-alert",
resolvers: ["rejectMediaBrowse"],
},
{
name: "generated dashboard",
path: "/?scenario=delayed-generated-dashboard#/climate",
loadingSelector: "#ha-launch-screen",
readySelector: "hui-view",
resolvers: ["resolveGeneratedDashboard"],
},
{
name: "Lovelace dashboard",
path: "/?scenario=delayed-lovelace#/lovelace",
loadingSelector: "#ha-launch-screen",
readySelector: "hui-card",
resolvers: ["resolveLovelaceConfig"],
},
];
for (const readinessCase of cases) {
// eslint-disable-next-line no-await-in-loop
await test.step(readinessCase.name, async () => {
await goToPanel(page, readinessCase.path);
const launchScreen = page.locator("#ha-launch-screen");
const loadingScreen = page.locator(readinessCase.loadingSelector);
const readyContent = page.locator(readinessCase.readySelector).first();
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(loadingScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(readyContent).not.toBeAttached();
await readinessCase.resolvers.reduce(
async (previousResolver, resolver, index) => {
await previousResolver;
await page.evaluate((resolverName) => {
window[resolverName]?.();
}, resolver);
if (index < readinessCase.resolvers.length - 1) {
await expect(launchScreen).toBeAttached();
await expect(loadingScreen).toBeAttached();
await expect(readyContent).not.toBeAttached();
}
},
Promise.resolve()
);
await expect(readyContent).toBeAttached({ timeout: PANEL_TIMEOUT });
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
});
}
});
// ---------------------------------------------------------------------------
@@ -227,40 +262,6 @@ test.describe("Initial readiness", () => {
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("keeps the launch screen until generated content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-view")).not.toBeAttached();
await page.evaluate(() => window.resolveGeneratedDashboard?.());
await expect(page.locator("hui-view")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("keeps the launch screen until initial content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-card")).not.toBeAttached();
await page.evaluate(() => window.resolveLovelaceConfig?.());
await expect(page.locator("hui-card").first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
+2
View File
@@ -94,6 +94,8 @@ declare global {
__mockHass: MockHomeAssistant;
rejectMediaBrowse?: () => void;
resolveCalendarRegistry?: () => void;
resolveConfigEntries?: () => void;
resolveConfigEntriesInProgress?: () => void;
resolveGeneratedDashboard?: () => void;
resolveLovelaceConfig?: () => void;
resolveMediaBrowse?: () => void;
+20
View File
@@ -185,6 +185,25 @@ const delayedCalendarScenario: Scenario = (hass) => {
hass.mockWS("config/entity_registry/list", () => registryPromise);
};
const delayedIntegrationsScenario: Scenario = (hass) => {
addLaunchScreen();
hass.mockWS(
"config_entries/subscribe",
(_msg, _currentHass, onChange?: (updates: unknown[]) => void) => {
window.resolveConfigEntries = () => onChange?.([]);
return () => undefined;
}
);
hass.mockWS(
"config_entries/flow/subscribe",
(_msg, _currentHass, onChange?: (updates: unknown[]) => void) => {
window.resolveConfigEntriesInProgress = () => onChange?.([]);
return () => undefined;
}
);
};
const delayedMediaBrowseScenario: Scenario = (hass) => {
addLaunchScreen();
@@ -230,6 +249,7 @@ export const scenarios: Record<string, Scenario> = {
"custom-theme": customThemeScenario,
"delayed-calendar": delayedCalendarScenario,
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
"delayed-integrations": delayedIntegrationsScenario,
"delayed-media-browse": delayedMediaBrowseScenario,
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
"light-more-info": lightMoreInfoScenario,
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { validateCardConfig } from "../../gallery/src/common/validate-card-config";
vi.hoisted(() => {
Object.assign(globalThis, {
__STATIC_PATH__: "/",
__BUILD__: "modern",
__VERSION__: "test",
__BACKWARDS_COMPAT__: false,
__SUPERVISOR__: false,
__NAMESPACE__: "frontend",
});
});
describe("validateCardConfig", () => {
it("accepts valid card configs", async () => {
await expect(
validateCardConfig({
type: "button",
entity: "light.bed_light",
tap_action: {
action: "perform-action",
perform_action: "light.toggle",
},
})
).resolves.toBeUndefined();
});
it.each([
{
type: "entity-filter",
},
{
type: "picture",
},
])("rejects invalid $type card configs", async (config) => {
await expect(validateCardConfig(config)).rejects.toThrow();
});
});
+143 -26
View File
@@ -60,14 +60,6 @@ const mountManager = async () => {
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
@@ -81,9 +73,12 @@ describe("notification-manager", () => {
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(toast.bottomOffset).toBe(0);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("0px");
expect(toast.show).toHaveBeenCalledOnce();
});
@@ -98,21 +93,34 @@ describe("notification-manager", () => {
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.timeoutMs).toBe(-1);
expect(toast.bottomOffset).toBe(16);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("16px");
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the current notification when duration is zero", async () => {
it("clears the latest anonymous notification when duration is zero without an ID", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({
id: "identified",
message: "Identified message",
duration: -1,
});
await manager.showDialog({ message: "Second message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["First message", "Identified message"]);
});
it.each([
@@ -190,6 +198,32 @@ describe("notification-manager", () => {
expect(primary).toHaveBeenCalledOnce();
});
it("invokes the action belonging to the selected stacked toast", async () => {
const manager = await mountManager();
const firstAction = vi.fn();
const secondAction = vi.fn();
await manager.showDialog({
id: "first",
message: "First",
action: { action: firstAction, text: "First action" },
duration: -1,
});
await manager.showDialog({
id: "second",
message: "Second",
action: { action: secondAction, text: "Second action" },
duration: -1,
});
manager.shadowRoot!.querySelectorAll("ha-button")[1].click();
expect(secondAction).toHaveBeenCalledOnce();
expect(firstAction).not.toHaveBeenCalled();
expect(
manager.shadowRoot!.querySelectorAll("ha-toast")[1].hide
).toHaveBeenCalledWith("action");
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
@@ -202,31 +236,114 @@ describe("notification-manager", () => {
expect(toast.labelText).toBe("Second");
});
it("hides a toast before replacing it with a different ID", async () => {
it("stacks toasts with different IDs", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "second", message: "Second" });
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(2);
expect([...toasts].map((toast) => toast.labelText)).toEqual([
"First",
"Second",
]);
});
it("ignores a stale replacement after a newer notification starts", async () => {
it("uses the largest bottom offset for the notification stack", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "first",
message: "First",
bottomOffset: 16,
});
await manager.showDialog({
id: "second",
message: "Second",
bottomOffset: 48,
});
expect(
manager
.shadowRoot!.querySelector<HTMLElement>(".stack")!
.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("48px");
});
it("closes only the toast matching a duration-zero ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "first", message: "", duration: 0 });
await manager.updateComplete;
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(1);
expect(toasts[0].labelText).toBe("Second");
});
it("keeps a same-ID update that arrives while the previous toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
let finishHide: () => void;
vi.mocked(toast.hide).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishHide = resolve;
})
);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
const closing = manager.showDialog({
id: "status",
message: "",
duration: 0,
});
await Promise.resolve();
await manager.showDialog({ id: "status", message: "Second" });
finishHide!();
await closing;
await manager.updateComplete;
expect(toast.labelText).toBe("Third");
const remainingToast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(remainingToast.labelText).toBe("Second");
});
it("keeps a frontend update visible throughout server startup", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "frontend-update-available",
message: "Frontend update available",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Home Assistant is starting",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Starting recorder",
duration: -1,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Starting recorder"]);
await manager.showDialog({
id: "server-startup",
message: "Home Assistant started",
duration: 5000,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Home Assistant started"]);
});
it("clears rendered state after the toast closes", async () => {
@@ -4,6 +4,7 @@ import "../../../../src/panels/config/script/manual-script-editor";
import type { HaManualScriptEditor } from "../../../../src/panels/config/script/manual-script-editor";
import { createMockHass } from "../../../fixtures/hass";
import { showPasteReplaceDialog } from "../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace";
import { PASTED_CONFIG_TOAST_ID } from "../../../../src/panels/config/automation/ha-manual-editor-mixin";
const pasteCases = [
{
@@ -163,6 +164,8 @@ describe("manual automation paste", () => {
once: true,
});
});
const notification = vi.fn();
el.addEventListener("hass-notification", notification);
// Call the protected method through `any` to avoid full DOM lifecycle.
await (el as any).handlePaste(makePasteEvent(paste));
@@ -171,6 +174,9 @@ describe("manual automation paste", () => {
const ev = await valueChanged;
expect(ev.detail.value).toEqual(expected);
expect(notification.mock.calls[0][0].detail.id).toBe(
PASTED_CONFIG_TOAST_ID
);
}
);
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsTargetHumidityCardFeature } from "../../../../src/panels/lovelace/card-features/hui-target-humidity-card-feature";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import type { HomeAssistant } from "../../../../src/types";
const entity = (
entityId: string,
attributes: HassEntity["attributes"] = {}
): HassEntity =>
({
entity_id: entityId,
state: "on",
attributes,
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
}) as HassEntity;
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
({
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
}) as unknown as HomeAssistant;
describe("supportsTargetHumidityCardFeature", () => {
it("supports a humidifier entity", () => {
const stateObj = entity("humidifier.test");
const hass = hassWith(stateObj);
expect(
supportsTargetHumidityCardFeature(hass, {
entity_id: stateObj.entity_id,
})
).toBe(true);
});
it("supports a climate entity with the TARGET_HUMIDITY feature flag", () => {
const stateObj = entity("climate.test", {
supported_features: ClimateEntityFeature.TARGET_HUMIDITY,
});
const hass = hassWith(stateObj);
expect(
supportsTargetHumidityCardFeature(hass, {
entity_id: stateObj.entity_id,
})
).toBe(true);
});
it("does not support a climate entity without the TARGET_HUMIDITY feature flag", () => {
const stateObj = entity("climate.test", { supported_features: 0 });
const hass = hassWith(stateObj);
expect(
supportsTargetHumidityCardFeature(hass, {
entity_id: stateObj.entity_id,
})
).toBe(false);
});
it("does not support other domains", () => {
const stateObj = entity("light.test");
const hass = hassWith(stateObj);
expect(
supportsTargetHumidityCardFeature(hass, {
entity_id: stateObj.entity_id,
})
).toBe(false);
});
it("does not support a context with no entity_id", () => {
const hass = hassWith();
expect(supportsTargetHumidityCardFeature(hass, {})).toBe(false);
});
});
+60 -65
View File
@@ -4032,15 +4032,15 @@ __metadata:
languageName: node
linkType: hard
"@octokit/auth-oauth-device@npm:8.0.3":
version: 8.0.3
resolution: "@octokit/auth-oauth-device@npm:8.0.3"
"@octokit/auth-oauth-device@npm:8.0.4":
version: 8.0.4
resolution: "@octokit/auth-oauth-device@npm:8.0.4"
dependencies:
"@octokit/oauth-methods": "npm:^6.0.2"
"@octokit/request": "npm:^10.0.6"
"@octokit/types": "npm:^16.0.0"
"@octokit/oauth-methods": "npm:^6.0.3"
"@octokit/request": "npm:^10.0.13"
"@octokit/types": "npm:^17.0.0"
universal-user-agent: "npm:^7.0.0"
checksum: 10/33b9df5ebe971226b44bfe876ba5c188c687804b6efbdca980c2e78106fbd713439d73edc451331c7ea0095b09149a8f47e0b727e6c90d78b94dc7e4caf1aa3c
checksum: 10/1ea5a7844f98d3f92a303e84984e5fa568253ccb1acfe1ca76d256a0e94d220978c8645d2e5206e1bd55c318511dec6aac864b4156751113beeb24c435eb935a
languageName: node
linkType: hard
@@ -4094,15 +4094,15 @@ __metadata:
languageName: node
linkType: hard
"@octokit/oauth-methods@npm:^6.0.2":
version: 6.0.2
resolution: "@octokit/oauth-methods@npm:6.0.2"
"@octokit/oauth-methods@npm:^6.0.3":
version: 6.0.3
resolution: "@octokit/oauth-methods@npm:6.0.3"
dependencies:
"@octokit/oauth-authorization-url": "npm:^8.0.0"
"@octokit/request": "npm:^10.0.6"
"@octokit/request-error": "npm:^7.0.2"
"@octokit/types": "npm:^16.0.0"
checksum: 10/b5a9f9d361f797595e188d6e8c0286f82030df9cf6bf81023248034b85315a1180d0710aed3c255c5399f418d271a4489b169becba4b2294525a689cade85872
"@octokit/request": "npm:^10.0.13"
"@octokit/request-error": "npm:^7.1.1"
"@octokit/types": "npm:^17.0.0"
checksum: 10/cfa36ce9c2771e1791211ff48c80c469eef618b5be4c3ac74b51692fcf1189637da68b3849ed5d2e2193bdcea30b7df4f3de56b7906a31cf3b5ccbab500f93db
languageName: node
linkType: hard
@@ -4113,6 +4113,13 @@ __metadata:
languageName: node
linkType: hard
"@octokit/openapi-types@npm:^28.0.0":
version: 28.0.0
resolution: "@octokit/openapi-types@npm:28.0.0"
checksum: 10/031cebd35b7a110771ab9909d4d2f8ac2dba7185b55ea2ba91dfa0736f397797c768b7c12b12290e9cb42f1dd1d157ffe829d2009be8542e227780e588a40c68
languageName: node
linkType: hard
"@octokit/plugin-paginate-rest@npm:^14.0.0":
version: 14.0.0
resolution: "@octokit/plugin-paginate-rest@npm:14.0.0"
@@ -4144,39 +4151,39 @@ __metadata:
languageName: node
linkType: hard
"@octokit/plugin-retry@npm:8.1.0":
version: 8.1.0
resolution: "@octokit/plugin-retry@npm:8.1.0"
"@octokit/plugin-retry@npm:8.1.1":
version: 8.1.1
resolution: "@octokit/plugin-retry@npm:8.1.1"
dependencies:
"@octokit/request-error": "npm:^7.0.2"
"@octokit/types": "npm:^16.0.0"
"@octokit/request-error": "npm:^7.1.1"
"@octokit/types": "npm:^17.0.0"
bottleneck: "npm:^2.15.3"
peerDependencies:
"@octokit/core": ">=7"
checksum: 10/0bccaa14ef295ac5dc3e6fa96bb7c555b8b188dfe0bf1db5ea83acb29af375bf08a43e0d44c42941608afc6ab414b6dcdfb44881a8e8346b963d7501e0aea766
checksum: 10/ee42b70c04b6749362093bf79d3b5f4019a3a5e3cdf79e28e9d781e4a43242aa67781d1e71dba069646ea6a0b442e155403877493cd3e194e7045386b26023d7
languageName: node
linkType: hard
"@octokit/request-error@npm:^7.0.2":
version: 7.1.0
resolution: "@octokit/request-error@npm:7.1.0"
"@octokit/request-error@npm:^7.0.2, @octokit/request-error@npm:^7.1.1":
version: 7.1.1
resolution: "@octokit/request-error@npm:7.1.1"
dependencies:
"@octokit/types": "npm:^16.0.0"
checksum: 10/c1d447ff7482382c69f7a4b2eaa44c672906dd111d8a9196a5d07f2adc4ae0f0e12ec4ce0063f14f9b2fb5f0cef4451c95ec961a7a711bd900e5d6441d546570
"@octokit/types": "npm:^17.0.0"
checksum: 10/78f91331f523e004e6e1b505bfe907aede05cd8da25abb16c62df40c31e4dceed5d979a6f30de0c088f7b03e7676495af5e282c54320ad8805864e6ee82d02f3
languageName: node
linkType: hard
"@octokit/request@npm:^10.0.6":
version: 10.0.11
resolution: "@octokit/request@npm:10.0.11"
"@octokit/request@npm:^10.0.13, @octokit/request@npm:^10.0.6":
version: 10.0.13
resolution: "@octokit/request@npm:10.0.13"
dependencies:
"@octokit/endpoint": "npm:^11.0.3"
"@octokit/request-error": "npm:^7.0.2"
"@octokit/types": "npm:^16.0.0"
"@octokit/request-error": "npm:^7.1.1"
"@octokit/types": "npm:^17.0.0"
content-type: "npm:^2.0.0"
json-with-bigint: "npm:^3.5.3"
universal-user-agent: "npm:^7.0.2"
checksum: 10/036640f49e4ab63470130dccafb828822a18cf9ce060b8170e56ba9ce7173029db92b3c37ad7474acf2c097180d5127754b69d3ffbe60162489aed4324b3cb8f
checksum: 10/6edce2ab7c5cbb29c65374929f5342c00445a6b0bb4788b2da23bbfe6844456949f5fcc3f1b17b955902215723836b72fa474e31d051df98f4f90dfb7c62ebe5
languageName: node
linkType: hard
@@ -4201,6 +4208,15 @@ __metadata:
languageName: node
linkType: hard
"@octokit/types@npm:^17.0.0":
version: 17.0.0
resolution: "@octokit/types@npm:17.0.0"
dependencies:
"@octokit/openapi-types": "npm:^28.0.0"
checksum: 10/38db90d8a2dde153b84a26b6ba2aa4e4a8ddda03dd13d6b4a6f075ffe850c946b6852ba77457e4de8c07b10d06f59d174223e69243c88ecb5cf6280ee541601e
languageName: node
linkType: hard
"@oxc-project/types@npm:=0.139.0":
version: 0.139.0
resolution: "@oxc-project/types@npm:0.139.0"
@@ -9714,16 +9730,6 @@ __metadata:
languageName: node
linkType: hard
"gulp-brotli@npm:3.0.0":
version: 3.0.0
resolution: "gulp-brotli@npm:3.0.0"
dependencies:
plugin-error: "npm:^1.0.1"
through2: "npm:^3.0.1"
checksum: 10/0eea1fc60ae7f256184155b61a30a916007d21d37234698d8cdb299f64f71b4d68ca3182528e7da5d71290079c32c0228573578b76f5af7af7230c31537ef9d2
languageName: node
linkType: hard
"gulp-cli@npm:^3.1.0":
version: 3.1.0
resolution: "gulp-cli@npm:3.1.0"
@@ -9915,8 +9921,8 @@ __metadata:
"@material/web": "npm:2.5.0"
"@mdi/js": "npm:7.4.47"
"@mdi/svg": "npm:7.4.47"
"@octokit/auth-oauth-device": "npm:8.0.3"
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/auth-oauth-device": "npm:8.0.4"
"@octokit/plugin-retry": "npm:8.1.1"
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
@@ -9979,7 +9985,6 @@ __metadata:
glob: "npm:13.0.6"
globals: "npm:17.8.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
hls.js: "npm:1.6.16"
@@ -12977,17 +12982,6 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
inherits: "npm:^2.0.3"
string_decoder: "npm:^1.1.1"
util-deprecate: "npm:^1.0.1"
checksum: 10/d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048
languageName: node
linkType: hard
"readable-stream@npm:^2.3.5, readable-stream@npm:~2.3.6":
version: 2.3.8
resolution: "readable-stream@npm:2.3.8"
@@ -13003,6 +12997,17 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
inherits: "npm:^2.0.3"
string_decoder: "npm:^1.1.1"
util-deprecate: "npm:^1.0.1"
checksum: 10/d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048
languageName: node
linkType: hard
"readable-stream@npm:^4.7.0":
version: 4.7.0
resolution: "readable-stream@npm:4.7.0"
@@ -14561,16 +14566,6 @@ __metadata:
languageName: node
linkType: hard
"through2@npm:^3.0.1":
version: 3.0.2
resolution: "through2@npm:3.0.2"
dependencies:
inherits: "npm:^2.0.4"
readable-stream: "npm:2 || 3"
checksum: 10/98bdffba8e877fd8beb2154adc4eb0d52fad281130f56f6e5d18f85d1e1aa528a7b27317b302eb5443f6636ab045d3c272e6dffc61d984775db284823b90532d
languageName: node
linkType: hard
"time-stamp@npm:^1.0.0":
version: 1.1.0
resolution: "time-stamp@npm:1.1.0"