Compare commits

...
38 Commits
Author SHA1 Message Date
17d08d26f6 Wrap long descriptions in the entity settings (#55590)
* Wrap supporting text of entity settings rows

Long explanations such as the visibility description were truncated
with an ellipsis, especially in languages with longer translations.

Fixes #55584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGRG4gEJgUauHeEZcB

* Update supporting-text styles for row and list items

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 14:34:40 +01:00
Alexander Hass aa06e8ce25 Fix README images and links with a relative path in the Marketplace (#55525)
* Keep relative README addresses for the Marketplace rewrite

Images and links in a README with a plain relative path, such as `images/logo.png`, were broken on the Marketplace repository page. xss empties every `src` and `href` that does not start with a scheme, `/`, `./`, `../` or `#`, so the path was gone before `rewriteHtmlUrls` could point it at the repository.

`renderMarkdown` takes `allowRelativeUrls`, which `ha-markdown-element` sets when it rewrites the addresses afterwards. Such an address of a link, image or source is kept as a path below the current one, so it can never carry a scheme, and xss checks it like any other address.

* Trim a relative README address before it gets the ./ prefix

xss trims the spaces written around an attribute value, but not encoded ones. A relative path such as `&#32;images/logo.png` therefore got the space inside the path after the ./ prefix, and the image stayed broken. The value is now decoded and trimmed first, as the browser trims the address.

* Decode a relative README address once and keep data links first

The relative address was decoded before `safeAttrValue` decoded it again, so `&#38;#105;mages` was checked and shown as `images` while the browser reads `&#105;mages`. Like `srcset`, it is now decoded once with `&amp;` for query strings, dropped when entities are left over, and written back escaped, so the browser reads the path that was checked.

Links to data URLs allowed with `allowDataUrl` are handled before relative addresses again, so they are no longer turned into paths.

* Escape a kept relative README address only once

`safeAttrValue` returns the value escaped already, so escaping its result again turned a quote in a path into `&amp;quot;`, and the browser requested another file. It is now only the check, and the decoded path is escaped once, like `srcset`.

The comments of the change are cut down to the one above the new function, as the code around has few.

* Drop relative README addresses with a scheme and reuse URL_ATTRIBUTES

A relative path never has a scheme, so an address xss empties that still has one after decoding (HTTPS://..., j:alert(1) from &#0000106avascript:) or is empty is dropped instead of being kept below ./, where it would request a path in the repository that does not exist. The worker imports URL_ATTRIBUTES from rewrite-html-urls, so the attributes it keeps and the ones the rewrite touches cannot drift apart.
2026-11-03 14:34:39 +01:00
Bram Kragten 122877cdf5 Bumped version to 20261028.3 2026-11-02 16:42:17 +01:00
Josef Zweck a103eac243 Add access risk to the Marketplace warning (#55559) 2026-11-02 16:41:33 +01:00
Paul Bottein a9a8a9aad9 Show the area first in integration device rows (#55557) 2026-11-02 16:41:32 +01:00
WendelinandClaude Opus 5.5 35a054c1ba Fix picker rows missing the first click after scrolling (#55556)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:31 +01:00
960e99ed66 Refactor bulk delete labels (#55555)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-02 16:41:29 +01:00
Paul Bottein 232375befb Limit the more info activity to the 100 most recent entries (#55553) 2026-11-02 16:41:28 +01:00
Aidan Timson 91ee29a2e6 Use generic picker for install another version (#55552) 2026-11-02 16:41:26 +01:00
Aidan Timson a6dc5d0008 Shorten the Marketplace description on the settings page (#55549) 2026-11-02 16:41:25 +01:00
Thomas Mueller 7a66de362e Seek on slider change in the media control card (#55542)
* Seek on slider change in the media control card

A touch drag ends without a click, so the card never seeked after a
drag. Use the slider's change event, which fires when a drag or tap
ends and on arrow keys.

* Remove media control card rendering test

The frontend testing guidance asks for tests of code that computes
something, not rendering tests.
2026-11-02 16:41:23 +01:00
Thomas Mueller 36500e5b19 Fix media progress slider on Chromium < 125 (#55539)
matches(":state(dragging)") throws before Chromium 125. Use
customStates.has("dragging") instead.
2026-11-02 16:41:22 +01:00
karwosts df88136147 Fix date-only attribute formatting to not shift into other days (#55536) 2026-11-02 16:41:21 +01:00
jimjaeger 2f1d738c2c Fix onboarding using US customary units when the location is detected (#55535)
Keep the detected unit system in onboarding
2026-11-02 16:41:19 +01:00
Paulus SchoutsenandClaude bfeb26b47d Link the MCP integration pages to the AI settings (#55515)
The AI settings show how to connect to the MCP server and the URL of
each of its APIs. The MCP client and MCP server integration pages now
have an "AI settings" button that opens the MCP card on that page. The
button is the primary action when the page has no "Add entry" button.

The AI settings page scrolls to the section named in the URL hash, like
the backup settings page, and clears the hash after. In the demo, the
hash is the route of the page, so the button links to the page without
a section and the page keeps a hash that names no section.


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

Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:18 +01:00
Joost LekkerkerkerandClaude 8de5bfee89 Stretch chip container to fill host so outline matches host shape (#55510)
Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:17 +01:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 1efc7f8a95 Fix network graph button spacing (#55501)
* Fix zoom reset button spacing

* Replace margin-right with margin-inline-end

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Align with chart-base

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-02 16:41:15 +01:00
Erwin DounaandAidan Timson 9e0d38fba8 Show a skeleton while the live log loads (#55443)
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 16:41:13 +01:00
2932f8f528 Add embeddable dashboard card components to the demo (#54348)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 16:41:12 +01:00
Bram Kragten 6a0902d18c Bumped version to 20261028.2 2026-10-30 23:57:41 +01:00
Paul Bottein 4424f98eb6 Add translation for the Marketplace panel title (#55486) 2026-10-30 23:57:29 +01:00
Maarten Lakerveld ea13594fb0 Move the panel background stacking context off partial-panel-resolver (#55485)
Since #54801 the resolver creates a stacking context (isolation: isolate) so
its fixed ::before background sits behind the panel. The resolver is rendered
conditionally, once the startup data is available, and isolating it can
intermittently leave the Android WebView accessibility tree (Chromium
109-124) with an empty panel subtree while the page paints normally. UI
automation then never sees the dashboard; the companion app nightly E2E has
hit this on the legacy build (API 32/33/35) since the change landed.

Move the isolation to home-assistant-main's host, which exists before the
resolver is rendered. The pseudo-element and the Lovelace view background
both stay at z-index -1 in that context, in the same order as before, so the
themed background behaves exactly as #54801 intended. Measured with Maestro
on a Chrome 109 emulator: 0 stale trees in 62 cold loads, against 11 in 100
with the isolation on the resolver.
2026-10-30 23:57:28 +01:00
Daan Vervacke ce726a8a3f Wrap long title in YAML-only integration dialog (#55481) 2026-10-30 23:57:26 +01:00
Paul Bottein d67479da6d Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-30 23:57:25 +01:00
Bram Kragten 275e30879c Bumped version to 20261028.1 2026-10-29 16:44:20 +01:00
Maarten Lakerveld 7b53005865 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 16:43:58 +01:00
Paul Bottein 94950fcacd Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 16:43:56 +01:00
Petar Petrov 4214fe46c6 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 16:43:55 +01:00
Aidan Timson d6325eef7d Fix the Marketplace search field overlapping the top bar icons on narrow screens (#55439)
Let the narrow data table search field shrink to fit
2026-10-29 16:43:54 +01:00
WendelinandClaude Opus 5.5 12e722f122 Wrap long text in debug tool and network discovery rows (#55438)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-29 16:43:53 +01:00
Paul Bottein 83eb3ca13a Use the state color by default in alert cards and badges (#55436) 2026-10-29 16:43:51 +01:00
Petar Petrov 0532b1f5f6 Ignore YAML dashboard update event on first load (#55430) 2026-10-29 16:43:50 +01:00
Tristan Cartledge 970afb588d Fix duplicated hidden areas when reordering areas in the areas dashboard editor (#55424) 2026-10-29 16:43:49 +01:00
Maarten Lakerveld 23137ce6d2 Name maintenance problem tiles after the device and the entity (#55420)
Problem binary sensors were added to the maintenance view using the
battery tile builder, which names every tile after its device. A device
with several problem sensors (overheating, overvoltage, ...) then showed
identical tiles. Problem tiles now use a device plus entity name; the
entity part is dropped automatically when the sensor has no name of its
own, so single-sensor devices still read as just the device name.
Battery tiles keep the device-only name.

Fixes home-assistant/frontend#55413
2026-10-29 16:43:48 +01:00
JonathanandXyolyp 6b022d63e8 Preselect the only allowed unit also in an optional unit selector (#55417)
Co-authored-by: Xyolyp <[email protected]>
2026-10-29 16:43:46 +01:00
Paul Bottein d3c6e132b7 Show multi-select chips in a field with an inline add button (#55416) 2026-10-29 16:43:45 +01:00
Paul Bottein c08b261d4b Keep the section row divider under the entities card header (#55415) 2026-10-29 16:43:44 +01:00
Bram KragtenandClaude Opus 5.5 0721b22036 Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 16:43:43 +01:00
97 changed files with 2971 additions and 423 deletions

No files matched your search

+3
View File
@@ -284,6 +284,7 @@ module.exports.config = {
name: "demo" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
...module.exports.mapWorkerEntry(),
},
outputPath: outputPath(paths.demo_output_root, latestBuild),
@@ -291,6 +292,8 @@ module.exports.config = {
defineOverlay: {
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
__DEMO__: true,
// The embed sets the global to load static files from its own server.
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
},
isProdBuild,
latestBuild,
+5 -1
View File
@@ -232,7 +232,11 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
gulp.task(
"gen-pages-demo-dev",
+9 -1
View File
@@ -246,7 +246,15 @@ const createRspackConfig = ({
},
],
},
],
// The demo embed reads its own URL at runtime to find its files.
// Keep import.meta.url instead of replacing it with the source path.
// Development builds wrap modules in eval(), which rejects import.meta.
latestBuild &&
isProdBuild && {
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
parser: { importMeta: false },
},
].filter(Boolean),
},
optimization: {
minimizer: [
+7
View File
@@ -16,3 +16,10 @@
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
+19
View File
@@ -0,0 +1,19 @@
import "./public-path";
// The provider must be defined first. Custom elements upgrade in the order
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
import { fontStyles } from "../../../src/resources/roboto";
// Font faces only work in the document, not in the shadow root of the card.
// Defining Roboto does not change the page unless it already uses Roboto.
document.head.append(
Object.assign(document.createElement("style"), {
textContent: fontStyles.cssText,
})
);
// Custom cards use the helpers to create built-in cards. The dashboard panel
// sets the same global in the full app.
(window as any).loadCardHelpers = () =>
import("../../../src/panels/lovelace/custom-card-helpers");
+158
View File
@@ -0,0 +1,158 @@
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card .hass=${this._hass} .config=${this.config}></hui-card>
</div>`;
}
static styles = [
embedThemeStyles,
css`
:host {
display: block;
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
+277
View File
@@ -0,0 +1,277 @@
import { ContextProvider, provide } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { provideHass } from "../../../src/fake_data/provide_hass";
import type { DemoArea } from "../stubs/area_registry";
import { toAreaRegistryEntry } from "../stubs/area_registry";
import { mockAuth } from "../stubs/auth";
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import { newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
import { mockLogbook } from "../stubs/logbook";
import { mockMediaPlayer } from "../stubs/media_player";
import { mockRecorder } from "../stubs/recorder";
import { mockSensor } from "../stubs/sensor";
import { mockTemplate } from "../stubs/template";
import { mockTodo } from "../stubs/todo";
import { mockWeather } from "../stubs/weather";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
/** The data of a provider, as set by its script child. */
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
// "living_room" becomes "Living room"
const areaName = (areaId: string) => {
const name = areaId.replaceAll("_", " ");
return name.charAt(0).toUpperCase() + name.slice(1);
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@provide({ context: embedHassContext })
@property({ attribute: false })
public hass!: MockHomeAssistant;
/**
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
@property({ attribute: false }) public areas?: DemoArea[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
*/
@property({ attribute: false }) public calendars?: Record<
string,
CalendarEventApiData[]
>;
/** Energy preferences. Without them, energy is not set up. */
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
/** Light or dark mode of the cards. Without it, the browser setting. */
@property({ attribute: "color-scheme" }) public colorScheme?:
"light" | "dark";
private _reducedMotion = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
private _unsubMediaQueries: (() => void)[] = [];
private _scriptRead = false;
private _applyColorScheme() {
const dark = this.colorScheme
? this.colorScheme === "dark"
: this._prefersDark;
if (this.hass.themes.darkMode !== dark) {
this.hass.updateHass({
themes: { ...this.hass.themes, darkMode: dark },
selectedTheme: { ...this.hass.selectedTheme!, dark },
});
}
}
constructor() {
super();
// Set up in the constructor so that the contexts exist before the
// child cards connect and request them.
const hass = provideHass(this);
hass.updateTranslations(null);
hass.updateTranslations("lovelace");
mockIcons(hass);
mockAuth(hass);
mockHistory(hass);
mockRecorder(hass);
mockTemplate(hass);
mockSensor(hass);
mockMediaPlayer(hass);
mockTodo(hass);
mockWeather(hass);
mockLogbook(hass);
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "logbook"],
},
});
mockCalendarEvents(hass, (entityId, currentHass) => {
if (!(entityId in currentHass.states)) {
return null;
}
const calendarIds = Object.keys(currentHass.states).filter((id) =>
id.startsWith("calendar.")
);
return (
this.calendars?.[entityId] ??
SAMPLE_CALENDAR_EVENTS[
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
]
);
});
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
// The registry of the entities, without settings such as calendar colors
hass.mockWS(
"config/entity_registry/list",
(_msg, currentHass): EntityRegistryEntry[] =>
Object.values(currentHass.entities).map((entity) => ({
...newEntry({
entity_id: entity.entity_id,
platform: entity.platform ?? "demo",
unique_id: entity.entity_id,
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
}))
);
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
...EMPTY_PREFERENCES,
...this.energy,
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
this._applyColorScheme();
}
public connectedCallback() {
super.connectedCallback();
this._unsubMediaQueries = [
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
this._prefersDark = matches;
this._applyColorScheme();
}),
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
this._reducedMotion.setValue(matches)
),
];
// Only once, so that moving the provider keeps the current entities
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptData(this) as
EntityInput[] | EmbedProviderData | undefined;
if (Array.isArray(data)) {
this.entities = data;
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.calendars = data.calendars;
this.energy = data.energy;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQueries.forEach((unsub) => unsub());
this._unsubMediaQueries = [];
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("colorScheme")) {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
// Entities that services changed keep their state when only the areas
// change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
this.hass.updateFormatFunctions();
}
}
}
protected updated(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass");
// The first update holds the initial entities. They are not changes.
if (!oldHass) {
return;
}
// Other updates, such as loaded translations, keep the same states object.
if (oldHass.states !== this.hass.states) {
fireEvent(this, "states-changed", { states: this.hass.states });
}
}
protected render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-provider": HaEmbedProvider;
}
interface HASSDomEvents {
"states-changed": { states: HassEntities };
}
}
+20
View File
@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
var __HA_STATIC_PATH__: string | undefined;
}
declare let __webpack_public_path__: string;
/* eslint-enable @typescript-eslint/naming-convention */
// Load chunks and static files from the server that hosts this script, so
// that other sites can embed the cards. Only the modern production build keeps
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
// builds only run on the demo site, where the default paths are correct.
// `new URL(path, import.meta.url)` in one expression is an asset reference for
// the bundler, so the script URL goes into a variable first.
if (__BUILD__ === "modern" && !__DEV__) {
const scriptUrl = import.meta.url;
__webpack_public_path__ = new URL(".", scriptUrl).href;
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
}
export {};
+17
View File
@@ -0,0 +1,17 @@
import { load } from "js-yaml";
/**
* Parses the data in a `<script type="application/json">` or
* `<script type="text/yaml">` child of the element.
*/
export const readScriptData = (element: HTMLElement): unknown => {
const script = element.querySelector<HTMLScriptElement>(
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
);
if (!script) {
return undefined;
}
return script.type === "text/yaml"
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
+61
View File
@@ -0,0 +1,61 @@
import { unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
import { themeStyles } from "../../../src/resources/theme/theme";
// The theme styles set variables on `html`, next to fonts and other page
// styles. Only the variables, media queries, and keyframes are copied, and
// `html` becomes `selector`. This keeps the theme inside the card, so that it
// does not change the page that embeds the card.
const scopeThemeStyles = (cssText: string, selector: string): string => {
// A style element in a separate document parses the CSS without applying it.
const doc = document.implementation.createHTMLDocument("");
const style = doc.createElement("style");
style.textContent = cssText;
doc.head.append(style);
const convert = (rules: CSSRuleList): string =>
Array.from(rules, (rule) => {
if (rule instanceof CSSStyleRule) {
if (rule.selectorText !== "html") {
return "";
}
const variables = Array.from(rule.style)
.filter((name) => name.startsWith("--"))
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
return `${selector}{${variables.join("")}}`;
}
if (rule instanceof CSSMediaRule) {
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
}
if (rule instanceof CSSKeyframesRule) {
return rule.cssText;
}
return "";
}).join("");
return convert(style.sheet!.cssRules);
};
const darkTheme = {
...darkSemanticVariables,
...darkColorVariables,
};
// Like processTheme in apply_themes_on_element, each hex color also sets its
// `--rgb-` variable, else those keep their light theme values
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
const variable = `--${name}:${value};`;
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
return [variable];
}
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
});
export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
+4
View File
@@ -36,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockLogbook } from "./stubs/logbook";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -50,6 +51,7 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import { mockWeather } from "./stubs/weather";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -190,7 +192,9 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant card components</title>
<%= renderTemplate("../../../src/html/_header.html.template") %>
<meta name="referrer" content="same-origin" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<style>
:root {
color-scheme: light;
--page-background: #fafafa;
--page-text: #212121;
--page-code-background: #e5e5e5;
--page-divider: #dddddd;
}
@media (prefers-color-scheme: dark) {
:root:not([data-scheme="light"]) {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
}
:root[data-scheme="dark"] {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
html {
background-color: var(--page-background);
color: var(--page-text);
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
margin: 0;
padding: 0 16px 64px;
}
main {
max-width: 880px;
margin: 0 auto;
}
h1 {
margin: 32px 0 8px;
}
h2 {
margin: 48px 0 8px;
}
pre {
background: var(--page-code-background);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
font-size: 13px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: start;
vertical-align: top;
padding: 8px;
border-bottom: 1px solid var(--page-divider);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
align-items: start;
margin: 16px 0;
}
.single {
max-width: 400px;
margin: 16px 0;
}
.log {
height: 160px;
overflow-y: auto;
margin: 0;
}
button {
font: inherit;
padding: 6px 12px;
margin-inline-end: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<h1>Home Assistant card components</h1>
<p>
Embed working Home Assistant dashboard cards in any page. The cards run
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<ul>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
</li>
</ul>
<p>
All cards in the same provider share their entities. When you change
an entity in one card, the other cards show the change.
</p>
<p>
Color scheme of this page and its cards:
<button data-scheme="">Auto</button>
<button data-scheme="light">Light</button>
<button data-scheme="dark">Dark</button>
</p>
<h2>Use on your site</h2>
<p>
Load the script from the demo site. The script loads all other files
from the demo site too.
</p>
<pre><code>&lt;script type="module" src="https://demo.home-assistant.io/embed.js"&gt;&lt;/script&gt;</code></pre>
<h2>One card</h2>
<p>
Put the entities in a script tag inside the provider. Put the card
configuration in a script tag inside the card. Both take JSON with
<code>type="application/json"</code> or YAML with
<code>type="text/yaml"</code>. The card configuration is the same as in
a dashboard. YAML needs its indentation. If your site minifies HTML,
make sure the minifier keeps the whitespace in script tags, or use
JSON.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
&lt;/script&gt;
&lt;/ha-embed-card&gt;
&lt;/ha-embed-provider&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<p>
The <code>entities</code> attribute of the provider and the
<code>config</code> attribute of the card also take JSON.
</p>
<h2>Cards with shared state</h2>
<p>
These three cards are in one provider. Turn on a light in one card. The
other cards update. The log below shows the events of the provider.
</p>
<ha-embed-provider id="shared">
<script type="application/json">
[
{
"entity_id": "light.living_room",
"state": "off",
"attributes": {
"friendly_name": "Living room",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 255
}
},
{
"entity_id": "switch.coffee_maker",
"state": "on",
"attributes": {
"friendly_name": "Coffee maker",
"device_class": "outlet"
}
},
{
"entity_id": "climate.hallway",
"state": "heat",
"attributes": {
"friendly_name": "Hallway",
"current_temperature": 19.5,
"temperature": 21,
"min_temp": 7,
"max_temp": 30,
"target_temp_step": 0.5,
"hvac_modes": ["heat", "off"],
"hvac_action": "heating",
"supported_features": 1
}
},
{
"entity_id": "sensor.outside_temperature",
"state": "12.3",
"attributes": {
"friendly_name": "Outside temperature",
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°C"
}
}
]
</script>
<div class="cards">
<ha-embed-card>
<script type="application/json">
{
"type": "tile",
"entity": "light.living_room",
"tap_action": { "action": "toggle" }
}
</script>
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: entities
title: Living room
entities:
- light.living_room
- switch.coffee_maker
- sensor.outside_temperature
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<pre class="log" id="log"><code>Use the cards to see events here.</code></pre>
<h2>Cards outside a provider</h2>
<p>
A card does not have to be inside a provider. A card outside a provider
uses the first provider on the page. Put one provider with all entities
at the top of the page, and put the cards anywhere below it. If the
page has no provider, the card adds an empty provider to the page.
</p>
<p>
To use a different provider, set the <code>provider</code> attribute of
the card to the ID of the provider.
</p>
<pre><code>&lt;ha-embed-provider id="living-room"&gt;…&lt;/ha-embed-provider&gt;
&lt;p&gt;Some text about the coffee maker.&lt;/p&gt;
&lt;ha-embed-card provider="living-room"&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: switch.coffee_maker
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<p>
These two cards are not inside a provider. The first card uses the
first provider on this page, the kitchen light at the top. The second
card uses the provider with the ID <code>shared</code> above.
</p>
<div class="cards">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<h2>Light and dark mode</h2>
<p>
The cards follow the light or dark mode of the browser. To choose the
mode, set the <code>color-scheme</code> attribute of the provider to
<code>light</code> or <code>dark</code>. Use this when your site has its
own switch for dark mode. The buttons at the top of this page do this.
The cards keep their styles inside. They do not change the styles of
your page.
</p>
<pre><code>&lt;ha-embed-provider color-scheme="dark"&gt;…&lt;/ha-embed-provider&gt;</code></pre>
<h2>Change entities from the page</h2>
<p>
Set the <code>entities</code> property to change entities from
JavaScript. The new list replaces all entities. To change one entity,
start from the current states.
</p>
<p>
<button id="set-temperature">Set a random outside temperature</button>
<button id="toggle-coffee">Toggle the coffee maker</button>
</p>
<pre><code>const provider = document.querySelector("ha-embed-provider");
provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
entity.entity_id === "sensor.outside_temperature"
? { ...entity, state: "18.1" }
: entity
);</code></pre>
<p>
To use the actions of an entity, call a service. The provider updates
the state and the cards.
</p>
<pre><code>provider.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});</code></pre>
<h2>Listen to changes</h2>
<p>
The provider fires a <code>states-changed</code> event when the state
of one or more entities changes. The event has the states of all
entities. The event bubbles.
</p>
<pre><code>provider.addEventListener("states-changed", (ev) =&gt; {
const { states } = ev.detail;
console.log(states["light.living_room"].state);
});</code></pre>
<p>
Read the current state of all entities with
<code>provider.hass.states</code>.
</p>
<h2>Areas, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
list in <code>entities</code>, and any of these keys:
</p>
<ul>
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities becomes an area.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
ID. Each event has a <code>summary</code>, a <code>start</code> and
an <code>end</code>. Calendars without events here get sample events
around today.
</li>
<li>
<code>energy</code>: the energy settings, the same as the
<code>energy/get_prefs</code> response. Without it, energy is not
set up.
</li>
</ul>
<p>
Weather entities get a forecast made up from their condition and
temperature, or their <code>forecast</code> attribute when they have
one. Set <code>supported_features</code> to show forecasts.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
entities:
- entity_id: calendar.family
state: "off"
attributes:
friendly_name: Family
areas:
- area_id: kitchen
name: Kitchen
calendars:
calendar.family:
- summary: Dentist
start: "2026-11-02T15:30:00+00:00"
end: "2026-11-02T16:15:00+00:00"
&lt;/script&gt;
…
&lt;/ha-embed-provider&gt;</code></pre>
<p>This calendar card uses the sample events.</p>
<ha-embed-provider>
<script type="application/json">
{
"entities": [
{
"entity_id": "calendar.family",
"state": "off",
"attributes": { "friendly_name": "Family" }
}
]
}
</script>
<div class="single">
<ha-embed-card
config='{ "type": "calendar", "entities": ["calendar.family"], "initial_view": "listWeek" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<h2>Custom cards</h2>
<p>
Custom cards work too. Load the script of the custom card on the page,
and use <code>custom:</code> and the element name as the card type.
The custom card gets the same mocked backend as the other cards.
<code>window.loadCardHelpers()</code> is available to create built-in
cards.
</p>
<p>
The mocked backend only knows a part of the Home Assistant API. History
and statistics are made up from the current state of each entity.
Templates are not rendered, they come back as they are. The logbook is
empty. Other requests get an error. From another site, the script of
the custom card needs CORS headers, like the embed script.
</p>
<pre><code>&lt;script type="module" src="https://example.com/embed-demo-custom-card.js"&gt;&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: custom:embed-demo-custom-card
entity: light.office
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<ha-embed-provider>
<script type="application/json">
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {
"friendly_name": "Office",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 120
}
}
]
</script>
<div class="single">
<ha-embed-card
config='{ "type": "custom:embed-demo-custom-card", "entity": "light.office" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<p>This is the code of the custom card:</p>
<pre><code>class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
&lt;style&gt;
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
&lt;/style&gt;
&lt;ha-card&gt;
&lt;span class="badge"&gt;Custom card&lt;/span&gt;
&lt;p&gt;
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
&lt;/p&gt;
&lt;div class="tile"&gt;&lt;/div&gt;
&lt;button&gt;Toggle from the custom card&lt;/button&gt;
&lt;/ha-card&gt;`;
this.shadowRoot.querySelector("button").addEventListener("click", () =&gt;
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pre>
<script type="module">
class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
<style>
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
</style>
<ha-card>
<span class="badge">Custom card</span>
<p>
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
</p>
<div class="tile"></div>
<button>Toggle from the custom card</button>
</ha-card>`;
this.shadowRoot.querySelector("button").addEventListener("click", () =>
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);
</script>
<h2>Reference</h2>
<h3><code>&lt;ha-embed-provider&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>entities</code></td>
<td>Attribute and property</td>
<td>
A list of entities. Each entity has an <code>entity_id</code>, a
<code>state</code>, and <code>attributes</code>. The optional keys
<code>area_id</code>, <code>device_id</code>, and
<code>platform</code> set the entity registry entry. A new list
replaces all entities. A JSON or YAML script child sets the first
list.
</td>
</tr>
<tr>
<td><code>color-scheme</code></td>
<td>Attribute and property (<code>colorScheme</code>)</td>
<td>
<code>light</code> or <code>dark</code>. Without it, the cards
follow the browser setting.
</td>
</tr>
<tr>
<td><code>hass</code></td>
<td>Property, read-only</td>
<td>The mocked Home Assistant object that the cards use.</td>
</tr>
<tr>
<td><code>states-changed</code></td>
<td>Event</td>
<td>
Fires when the state of one or more entities changes. The detail
has <code>states</code>, the states of all entities by entity ID.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-card&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The card configuration, the same as in a dashboard. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>provider</code></td>
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing.
</p>
</main>
<script>
(function () {
var shared = document.getElementById("shared");
var log = document.getElementById("log");
var first = true;
shared.addEventListener("states-changed", function (ev) {
var states = ev.detail.states;
var line =
new Date().toLocaleTimeString() +
" " +
Object.keys(states)
.map(function (entityId) {
return entityId + "=" + states[entityId].state;
})
.join(", ");
if (first) {
log.textContent = "";
first = false;
}
log.textContent = line + "\n" + log.textContent;
});
document
.getElementById("set-temperature")
.addEventListener("click", function () {
var states = shared.hass.states;
shared.entities = Object.keys(states).map(function (entityId) {
var entity = states[entityId];
return {
entity_id: entityId,
state:
entityId === "sensor.outside_temperature"
? (Math.random() * 30 - 5).toFixed(1)
: entity.state,
attributes: entity.attributes,
};
});
});
document
.querySelectorAll("button[data-scheme]")
.forEach(function (button) {
button.addEventListener("click", function () {
var scheme = button.getAttribute("data-scheme");
if (scheme) {
document.documentElement.setAttribute("data-scheme", scheme);
} else {
document.documentElement.removeAttribute("data-scheme");
}
document
.querySelectorAll("ha-embed-provider")
.forEach(function (provider) {
if (scheme) {
provider.setAttribute("color-scheme", scheme);
} else {
provider.removeAttribute("color-scheme");
}
});
});
});
document
.getElementById("toggle-coffee")
.addEventListener("click", function () {
shared.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});
});
})();
</script>
<%= renderTemplate("../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../src/html/_script_loader.html.template") %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
import "<%= latestEntryJS[0] %>";
+15 -13
View File
@@ -12,6 +12,20 @@ export interface DemoArea {
let areas: AreaRegistryEntry[] = [];
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
});
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
@@ -25,19 +39,7 @@ export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
areas = demoAreas.map(toAreaRegistryEntry);
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+122
View File
@@ -0,0 +1,122 @@
import type { CalendarEventApiData } from "../../../src/data/calendar";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample events relative to today, so demos always show something in the
// current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
export const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
export const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const toTime = (value: CalendarEventApiData["start"]) =>
new Date(
typeof value === "string"
? value
: "dateTime" in value
? value.dateTime
: value.date
).getTime();
const inRange = (
events: CalendarEventApiData[],
start: string | null,
end: string | null
): CalendarEventApiData[] => {
const startTime = start ? new Date(start).getTime() : -Infinity;
const endTime = end ? new Date(end).getTime() : Infinity;
return events.filter(
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
);
};
/**
* Answers event requests of calendar entities with the events of
* `getEvents`. A calendar without events gets `null`, like a calendar whose
* integration fails to return them.
*/
export const mockCalendarEvents = (
hass: MockHomeAssistant,
getEvents: (
entityId: string,
currentHass: MockHomeAssistant
) => CalendarEventApiData[] | null | undefined
) => {
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
const events = getEvents(msg.entity_id, currentHass);
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
return () => undefined;
});
// Home Assistant before 2026.5 has no event subscription, so cards that
// support it ask the REST API instead.
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
const url = new URL(path, location.origin);
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
const events = getEvents(entityId, currentHass);
if (!events) {
return Promise.reject(new Error("Calendar not found"));
}
return inRange(
events,
url.searchParams.get("start"),
url.searchParams.get("end")
);
});
};
+1 -1
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
const newEntry = (
export const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
+15
View File
@@ -0,0 +1,15 @@
import type { LogbookStreamMessage } from "../../../src/data/logbook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/**
* Answers logbook subscriptions with an empty logbook. No entries come from
* an automation or script trace.
*/
export const mockLogbook = (hass: MockHomeAssistant) => {
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] } satisfies LogbookStreamMessage);
return () => undefined;
});
hass.mockWS("trace/contexts", () => ({}));
};
+4 -1
View File
@@ -372,7 +372,10 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState && "last_reset" in entityState.attributes
entityState &&
("last_reset" in entityState.attributes ||
entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
? generateSumStatistics(
start,
end,
+133
View File
@@ -0,0 +1,133 @@
import type {
ForecastAttribute,
ModernForecastType,
} from "../../../src/data/weather";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600000;
const CONDITIONS = [
"partlycloudy",
"rainy",
"cloudy",
"sunny",
"pouring",
"partlycloudy",
"sunny",
];
// Daily changes around the current temperature, so the forecast looks real.
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
const PRECIPITATION = new Map([
["rainy", 2.4],
["pouring", 9.1],
]);
const forecastEntry = (
datetime: Date,
temperature: number,
condition: string
): ForecastAttribute => ({
datetime: datetime.toISOString(),
condition,
temperature: Math.round(temperature * 10) / 10,
precipitation: PRECIPITATION.get(condition) ?? 0,
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
});
const generateForecast = (
forecastType: ModernForecastType,
temperature: number,
condition: string
): ForecastAttribute[] => {
const conditions = [condition, ...CONDITIONS.slice(1)];
const now = new Date();
if (forecastType === "hourly") {
now.setMinutes(0, 0, 0);
return Array.from({ length: 24 }, (_, hour) => {
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
// Warmest mid-afternoon and coldest before dawn
const change =
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
return forecastEntry(
datetime,
temperature + change,
conditions[Math.floor(hour / 6)]
);
});
}
now.setHours(12, 0, 0, 0);
if (forecastType === "twice_daily") {
return conditions.flatMap((dayCondition, day) => {
const datetime = new Date(now.getTime() + day * 24 * HOUR);
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return [
{
...forecastEntry(datetime, dayTemperature, dayCondition),
is_daytime: true,
},
{
...forecastEntry(
new Date(datetime.getTime() + 12 * HOUR),
dayTemperature - 6,
dayCondition
),
is_daytime: false,
},
];
});
}
return conditions.map((dayCondition, day) => {
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return {
...forecastEntry(
new Date(now.getTime() + day * 24 * HOUR),
dayTemperature,
dayCondition
),
templow: Math.round((dayTemperature - 6) * 10) / 10,
};
});
};
/**
* Answers forecast subscriptions of weather entities. An entity with a
* `forecast` attribute gets that forecast. Other entities get a forecast made
* up from their condition and temperature.
*/
export const mockWeather = (hass: MockHomeAssistant) => {
hass.mockWS(
"weather/subscribe_forecast",
(
msg: { entity_id: string; forecast_type: ModernForecastType },
currentHass,
onChange
) => {
const stateObj = currentHass.states[msg.entity_id];
onChange?.({
type: msg.forecast_type,
forecast: stateObj
? (stateObj.attributes.forecast ??
generateForecast(
msg.forecast_type,
Number(stateObj.attributes.temperature ?? 15),
stateObj.state
))
: null,
});
return () => undefined;
}
);
};
+10 -77
View File
@@ -1,68 +1,14 @@
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../../../demo/src/stubs/calendar";
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
@@ -79,8 +25,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: at(0, 8),
end_time: at(0, 8, 30),
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
location: "",
description: "",
supported_features:
@@ -96,8 +42,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: at(0, 9),
end_time: at(0, 9, 15),
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
},
},
{
@@ -145,20 +91,7 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20261028.0"
version = "20261028.3"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -144,7 +144,8 @@ export class MediaProgressController implements ReactiveController {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
// matches(":state(dragging)") throws before Chromium 125.
if (slider.customStates.has("dragging")) {
return;
}
slider.value = this.progress ?? 0;
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
const URL_ATTRIBUTES = [
export const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
+15 -2
View File
@@ -1,5 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeCssColor } from "../color/compute-color";
import { computeDomain } from "./compute_domain";
import { stateColorCss } from "./state_color";
export const ALERT_DOMAINS = [
"alarm_control_panel",
@@ -8,8 +10,6 @@ export const ALERT_DOMAINS = [
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
@@ -25,3 +25,16 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
export const computeAlertColor = (
stateObj: HassEntity,
color: string
): string | undefined => {
if (color === "none") {
return undefined;
}
if (color === "state") {
return stateColorCss(stateObj);
}
return computeCssColor(color);
};
@@ -6,6 +6,7 @@ import {
} from "../../data/entity/entity_attributes";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
import type { WeatherEntity } from "../../data/weather";
import { getWeatherUnit } from "../../data/weather";
import type { HomeAssistant, ValuePart } from "../../types";
@@ -115,6 +116,24 @@ export const computeAttributeValueToParts = (
}
}
// Date only, which is timezone agnostic, so parse and format it as
// local time to keep the same calendar day
if (isDate(attributeValue)) {
const date = new Date(`${attributeValue}T00:00`);
if (checkValidDate(date)) {
return [
{
type: "value",
value: formatDate(
date,
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
}
}
// Value was not a timestamp, so only do date formatting
const date = new Date(attributeValue);
if (checkValidDate(date)) {
+15 -6
View File
@@ -89,6 +89,8 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -163,9 +165,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@@ -617,7 +620,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -1064,9 +1067,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
this._hasZoomableXAxis() &&
(this._zoomSliderHeld || start !== 0 || end !== 100)
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
);
}
@@ -2306,6 +2312,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
--ha-assist-chip-trailing-space: var(--ha-space-2);
--ha-assist-chip-icon-label-space: var(--ha-space-1);
}
.zoom-reset {
margin-inline-end: var(--ha-space-3);
}
`;
}
+1 -1
View File
@@ -648,7 +648,7 @@ export class HaNetworkGraph extends LitElement {
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
margin-inline-end: var(--ha-space-3);
}
`;
}
@@ -24,6 +24,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -105,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: LineSeriesOption[] = [];
@@ -159,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -22,6 +22,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -68,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: CustomSeriesOption[] = [];
@@ -105,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
></ha-chart-base>
`;
}
+2 -2
View File
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-line>
</div> `;
}
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-timeline>
</div> `;
};
+1
View File
@@ -352,6 +352,7 @@ export class HaChipBase extends HaButton {
}
.container {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
max-width: 100%;
+24 -21
View File
@@ -29,9 +29,9 @@ import type {
} from "../../common/translations/localize";
import { formattersContext, registriesContext } from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
@@ -121,6 +121,8 @@ export class HaEntityNamePicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-button.add") private _addButton?: HTMLElement;
private _editIndex?: number;
connectedCallback(): void {
@@ -159,7 +161,7 @@ export class HaEntityNamePicker extends LitElement {
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
${
this.allowCustom
? html`
@@ -264,7 +266,9 @@ export class HaEntityNamePicker extends LitElement {
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
this._items,
(item) => item,
@@ -290,22 +294,16 @@ export class HaEntityNamePicker extends LitElement {
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
<ha-button
class="add"
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._addItem}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.common.add")}
</ha-button>
</ha-chip-set>
</ha-sortable>
${this._renderRestoreButton()}
@@ -373,6 +371,10 @@ export class HaEntityNamePicker extends LitElement {
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
if (!value.length) {
await this.updateComplete;
this._addButton?.focus();
}
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
@@ -629,7 +631,7 @@ export class HaEntityNamePicker extends LitElement {
label {
display: block;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
}
.content {
@@ -722,7 +724,8 @@ export class HaEntityNamePicker extends LitElement {
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
padding: 0;
margin: var(--ha-space-1) 0 0;
}
`;
}
+4
View File
@@ -58,6 +58,10 @@ export class HaAnsiToHtml extends LitElement {
// The hidden lines each expand row shows when clicked.
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
public get hasLines(): boolean {
return !!this._pre?.childElementCount;
}
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
@@ -245,15 +245,15 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newOrder: string[] = [];
for (const floorId of floorIds) {
const isOnFloor = (areaId: string) =>
(this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR) === floorId;
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
newHidden.push(...(value.hidden ?? []));
// Reordering returns the hidden areas of every floor, not just this one
newHidden.push(...(value.hidden ?? []).filter(isOnFloor));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
const hidden = oldHidden.filter(isOnFloor);
if (hidden?.length) {
newHidden.push(...hidden);
}
+65 -33
View File
@@ -1,7 +1,9 @@
import type { ContextType } from "@lit/context";
import { mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -26,6 +28,10 @@ import "./chips/ha-input-chip";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-button";
import "./ha-svg-icon";
import "./ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
@@ -82,6 +88,8 @@ export class HaEntityTypePicker extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _titlesLoaded = false;
protected willUpdate() {
@@ -237,40 +245,13 @@ export class HaEntityTypePicker extends LitElement {
const localize = this._i18n?.localize;
const value = this.value ?? [];
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
const addLabel = localize?.("ui.common.add");
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${
value.length && chipLabels
? html`
<ha-chip-set>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels.get(key)!;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
<ha-generic-picker
.helper=${this.helper}
.label=${this.label}
.disabled=${this.disabled}
.value=${""}
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.searchFn=${this._searchFn}
@@ -278,10 +259,53 @@ export class HaEntityTypePicker extends LitElement {
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
no-sort
@value-changed=${this._itemAdded}
></ha-generic-picker>
>
<ha-chip-set
slot="field"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels?.get(key) ?? key;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-generic-picker>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const key = ev.detail.value;
@@ -320,7 +344,15 @@ export class HaEntityTypePicker extends LitElement {
width: 100%;
}
ha-chip-set {
padding: var(--ha-space-2) 0;
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-input-helper-text {
padding: 0;
margin-top: var(--ha-space-1);
}
`;
}
+2
View File
@@ -86,6 +86,7 @@ class HaMarkdownElement extends ReactiveElement {
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
rewriteUrl: Boolean(this.rewriteUrl),
});
}
@@ -99,6 +100,7 @@ class HaMarkdownElement extends ReactiveElement {
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
allowRelativeUrls: Boolean(this.rewriteUrl),
}
);
+4
View File
@@ -376,6 +376,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
virtual-focus
controlled
tabindex="0"
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
>
@@ -406,6 +407,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.value
)}
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@ha-list-visibility-changed=${this._visibilityChanged}
@@ -684,6 +686,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._resetListScroll();
};
// Keep focus in the search field, so a focus change doesn't scroll the
// list to the active row and make the click miss.
private _preventBlur(ev: Event) {
ev.preventDefault();
}
@@ -1,26 +1,28 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { ensureArray } from "../../common/array/ensure-array";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { internationalizationContext } from "../../data/context";
import type { SelectOption, SelectSelector } from "../../data/selector";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-checkbox";
import "../ha-dropdown-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-button";
import "../ha-input-helper-text";
import "../ha-select";
import "../ha-select-box";
import "../ha-sortable";
import "../ha-svg-icon";
import "../radio/ha-radio-group";
import "../radio/ha-radio-option";
@@ -28,10 +30,7 @@ import "../radio/ha-radio-option";
export class HaSelectSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
private _i18n!: HomeAssistantInternationalization;
@property({ attribute: false }) public selector!: SelectSelector;
@@ -48,6 +47,8 @@ export class HaSelectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
@@ -83,7 +84,11 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
caseInsensitiveStringCompare(
a.label,
b.label,
this._i18n.locale.language
)
);
}
@@ -158,67 +163,80 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
const addLabel = this._i18n.localize("ui.common.add");
return html`
${
value?.length
? html`
<ha-sortable
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)
?.label || item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${
options.find((option) => option.value === item)
?.label || item
}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
</ha-sortable>
`
this.label
? html`<span id="label" class="label chips-label"
>${this.label}</span
>`
: nothing
}
<ha-generic-picker
no-sort
.helper=${this.helper}
.label=${this.label}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${this.label}
.getItems=${this._getItems(options, value, true)}
.allowCustomValue=${this.selector.select.custom_value ?? false}
@value-changed=${this._comboBoxValueChanged}
></ha-generic-picker>
>
<ha-sortable
slot="field"
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
draggable-selector="ha-input-chip"
handle-selector="button.primary.action"
>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)?.label ||
item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-sortable>
</ha-generic-picker>
${this._renderHelper()}
`;
}
@@ -263,6 +281,11 @@ export class HaSelectSelector extends LitElement {
: "";
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _getOptions = memoizeOne(
(selector: SelectSelector) =>
selector.select?.options?.map((option) =>
@@ -403,19 +426,32 @@ export class HaSelectSelector extends LitElement {
ha-dropdown-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-chip-set {
padding: 8px 0;
}
.label {
display: block;
margin: 0 0 8px;
}
.chips-label {
font-weight: var(--ha-font-weight-medium);
}
ha-sortable ha-chip-set {
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-select-box + ha-input-helper-text {
margin-top: 4px;
}
ha-generic-picker + ha-input-helper-text {
padding: 0;
margin-top: 4px;
}
.checkbox-list ha-input-helper-text {
padding: 0;
margin-top: 4px;
@@ -98,8 +98,9 @@ export class HaUnitOfMeasurementSelector extends LitElement {
return;
}
if (this.required && units.length === 1 && units[0] !== null) {
// Preselect the only allowed unit
if (units.length === 1 && units[0] !== null) {
// Preselect the only allowed unit; "no unit" is not allowed either, so
// this also applies to an optional field
fireEvent(this, "value-changed", { value: units[0] });
}
}
@@ -0,0 +1,77 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { css, html, LitElement, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import "../animation/ha-fade-in";
import "./ha-skeleton-text";
// Varied widths so the placeholder reads as log lines rather than a block
const LINE_WIDTHS = [72, 58, 86, 64, 44, 78, 52, 90, 60, 70];
const LINE_HEIGHT_EM = 1.5;
/**
* Placeholder for log lines while a log loads. It covers its nearest
* positioned ancestor and shows as many whole lines as fit, so it never
* scrolls. Like the data table's loading rows, the label is given to assistive
* technology rather than shown.
*
* @cssprop --ha-skeleton-log-padding - The space around the lines. Defaults to `var(--ha-space-2)`.
*/
@customElement("ha-skeleton-log")
export class HaSkeletonLog extends LitElement {
@property() public label?: string;
private _lineCount = new ResizeController(this, {
callback: (entries) => {
const height = entries[0]?.contentRect.height ?? 0;
const lineHeight =
parseFloat(getComputedStyle(this).fontSize) * LINE_HEIGHT_EM;
return lineHeight ? Math.floor(height / lineHeight) : 0;
},
});
protected render() {
return html`
<ha-fade-in .duration=${300} easing="ease-in">
<div role="progressbar" aria-label=${ifDefined(this.label)}>
${Array.from(
{ length: this._lineCount.value ?? 0 },
(_, i) =>
html`<div class="line">
<ha-skeleton-text
style=${styleMap({
"--ha-skeleton-text-width": `${LINE_WIDTHS[i % LINE_WIDTHS.length]}%`,
})}
></ha-skeleton-text>
</div>`
)}
</div>
</ha-fade-in>
`;
}
static styles = css`
:host {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: var(--ha-skeleton-log-padding, var(--ha-space-2));
overflow: hidden;
}
.line {
display: flex;
align-items: center;
height: ${unsafeCSS(LINE_HEIGHT_EM)}em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-log": HaSkeletonLog;
}
}
@@ -43,6 +43,7 @@ export class MoreInfoLogbook extends LitElement {
.time=${this._time}
.entityIds=${this._entityIdAsList(this.entityId)}
name-detail="none"
max-entries="100"
narrow
no-icon
graph-color
+7 -1
View File
@@ -122,7 +122,13 @@ export class MockBaseEntity {
this.attributes = { ...this.attributes, ...changes.attributes };
}
this.hass!.updateStates({
// A removed or replaced entity can finish its transitions, so that their
// timers stop, but must not bring its state back
if (this.hass?.mockEntities[this.entityId] !== this) {
return;
}
this.hass.updateStates({
[this.entityId]: this.toState(),
});
}
+12
View File
@@ -0,0 +1,12 @@
import { MockBaseEntity } from "./base-entity";
import type { EntityAttributes } from "./types";
/**
* Entity for domains without their own mock, such as sensor, weather and
* calendar. Keeps all its attributes, as nothing here computes them.
*/
export class MockGenericEntity extends MockBaseEntity {
protected _getStateAttributes(): EntityAttributes {
return this.attributes;
}
}
+4 -2
View File
@@ -1,10 +1,11 @@
import { MockBaseEntity } from "./base-entity";
import type { MockBaseEntity } from "./base-entity";
import type { EntityInput } from "./types";
import { MockAlarmControlPanelEntity } from "./alarm-control-panel-entity";
import { MockAutomationEntity } from "./automation-entity";
import { MockClimateEntity } from "./climate-entity";
import { MockCoverEntity } from "./cover-entity";
import { MockFanEntity } from "./fan-entity";
import { MockGenericEntity } from "./generic-entity";
import { MockGroupEntity } from "./group-entity";
import { MockHumidifierEntity } from "./humidifier-entity";
import { MockInputNumberEntity } from "./input-number-entity";
@@ -49,5 +50,6 @@ const TYPES: Record<string, EntityConstructor> = {
export const getEntity = (input: EntityInput): MockBaseEntity => {
const [domain] = input.entity_id.split(".", 2);
return new (TYPES[domain] || MockBaseEntity)(input);
return new (TYPES[domain] || MockGenericEntity)(input);
};
+20 -8
View File
@@ -75,6 +75,8 @@ export interface MockHomeAssistant extends HomeAssistant {
updateHass(obj: Partial<MockHomeAssistant>);
updateStates(newStates: HassEntities);
addEntities(entities: EntityInput | EntityInput[], replace?: boolean);
// Recomputes the format functions, such as after the areas change.
updateFormatFunctions(): Promise<void>;
updateTranslations(fragment: null | string, language?: string);
addTranslations(translations: Record<string, string>, language?: string);
mockWS<T extends (...args) => any = any>(
@@ -301,14 +303,26 @@ export const provideHass = (
newEntities: EntityInput | EntityInput[],
replace = false
) {
if (replace) {
// Drop the old mock entities too, so that services cannot bring them back.
for (const entityId of Object.keys(entities)) {
delete entities[entityId];
}
}
const states = {};
// A new object, so that registry consumers see the change
const registry: HomeAssistant["entities"] = replace
? {}
: { ...hass().entities };
ensureArray(newEntities).forEach((input) => {
const ent = getEntity(input);
ent.hass = hass();
entities[ent.entityId] = ent;
states[ent.entityId] = ent.toState();
hass().entities[ent.entityId] = {
registry[ent.entityId] = {
entity_id: ent.entityId,
name: ent.attributes.friendly_name || undefined,
icon: undefined,
@@ -318,13 +332,10 @@ export const provideHass = (
device_id: ent.deviceId,
} satisfies EntityRegistryDisplayEntry;
});
if (replace) {
hass().updateHass({
states,
});
} else {
updateStates(states);
}
hass().updateHass({
entities: registry,
states: replace ? states : { ...hass().states, ...states },
});
updateFormatFunctions();
}
@@ -540,6 +551,7 @@ export const provideHass = (
return hass().localize;
},
addEntities,
updateFormatFunctions,
mockWS(type, callback) {
wsCommands[type] = callback;
},
@@ -1064,6 +1064,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
align-items: center;
color: var(--secondary-text-color);
}
.search-toolbar ha-input-search {
min-width: 0;
}
.filters {
--mdc-text-field-fill-color: var(--input-fill-color);
--mdc-text-field-idle-line-color: var(--input-idle-line-color);
+7 -1
View File
@@ -180,6 +180,13 @@ export class HomeAssistantMain extends LitElement {
color: var(--primary-text-color);
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
/* Keep the negative z-index panel backgrounds (the resolver's ::before
and the Lovelace view background) in this host's stacking context.
Isolating the conditionally rendered partial-panel-resolver instead
can intermittently leave the Android WebView (Chromium 109-124)
accessibility tree with an empty panel subtree while the page paints
normally, which breaks UI automation such as the companion app E2E. */
isolation: isolate;
--ha-sidebar-width: calc(56px + var(--safe-area-inset-left, 0px));
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--safe-area-content-inset-left: 0px;
@@ -201,7 +208,6 @@ export class HomeAssistantMain extends LitElement {
partial-panel-resolver {
display: block;
height: 100%;
isolation: isolate;
}
partial-panel-resolver::before {
content: "";
+3 -2
View File
@@ -197,9 +197,10 @@ class OnboardingCoreConfig extends LitElement {
longitude: this._location[1],
elevation: Number(this._elevation),
unit_system:
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
this._unitSystem ||
(["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric",
: "metric"),
time_zone: this._timeZone,
currency: this._currency || countryCurrency[this._country] || "EUR",
country: this._country,
@@ -942,6 +942,7 @@ class HaConfigAreaPage extends LitElement {
:host {
display: block;
height: 100%;
}
.container {
display: flex;
@@ -429,9 +429,7 @@ class HaBlueprintOverview extends LitElement {
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.blueprint.overview.delete_selected"
)}
.label=${this.hass.localize("ui.common.delete")}
.disabled=${this._usageCountsLoading || !this._selected.length}
@click=${this._removeSelected}
></ha-assist-chip>
+27 -1
View File
@@ -1,5 +1,7 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { replaceCurrentUrl } from "../../../common/navigate";
import { nextRender } from "../../../common/util/render-status";
import "../../../layouts/hass-subpage";
import "./ai-task-pref";
import "./mcp-pref";
@@ -11,6 +13,26 @@ class HaConfigSectionAI extends LitElement {
@property({ type: Boolean }) public narrow = false;
public connectedCallback(): void {
super.connectedCallback();
this._scrollToSection();
}
private async _scrollToSection() {
const hash = window.location.hash.substring(1);
if (!hash) {
return;
}
await nextRender();
// In the demo, the hash is the route of the page, not a section.
const section = this.shadowRoot!.getElementById(hash);
if (!section) {
return;
}
section.scrollIntoView();
replaceCurrentUrl(window.location.pathname);
}
protected render() {
return html`
<hass-subpage
@@ -24,7 +46,11 @@ class HaConfigSectionAI extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
></ai-task-pref>
<mcp-pref .hass=${this.hass} .narrow=${this.narrow}></mcp-pref>
<mcp-pref
id="mcp"
.hass=${this.hass}
.narrow=${this.narrow}
></mcp-pref>
</div>
</hass-subpage>
`;
@@ -1645,6 +1645,7 @@ export class HaConfigDevicePage extends LitElement {
css`
:host {
display: block;
height: 100%;
}
.container {
display: flex;
@@ -1923,8 +1923,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
margin-inline-start: 0;
}
ha-row-item.main-entity::part(supporting-text),
ha-row-item.device-area::part(supporting-text) {
ha-row-item::part(supporting-text),
ha-list-item-button::part(supporting-text) {
white-space: normal;
}
ha-row-item {
@@ -19,6 +19,7 @@ import type { HaLogViewer } from "../../../components/ha-log-viewer";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import "../../../components/skeleton/ha-skeleton-log";
import {
connectionContext,
internationalizationContext,
@@ -51,6 +52,9 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
@state() private _live = false;
// The backend answers the subscription once it has read the backlog
@state() private _loading = false;
@state() private _search = "";
@state() private _wrapLines = true;
@@ -84,9 +88,11 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
(lines) => this._addLines(lines)
);
this._unsub = unsub;
this._loading = true;
unsub.then(
() => {
if (this._unsub === unsub) {
this._loading = false;
this._live = true;
}
},
@@ -95,6 +101,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
return;
}
this._unsub = undefined;
this._loading = false;
if (err?.code === ERR_UNKNOWN_COMMAND) {
this._unsupported = true;
} else if (
@@ -130,6 +137,7 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
// _subscribe reports subscription errors, so only ignore them here
this._unsub?.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
this._loading = false;
this._live = false;
}
@@ -209,12 +217,18 @@ class DialogLiveLog extends DialogMixin<LiveLogDialogParams>(LitElement) {
${
this._lineCount
? nothing
: html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.waiting",
{ integration: this.params.name }
)}
</div>`
: this._loading
? html`<ha-skeleton-log
.label=${this._i18n.localize(
"ui.panel.config.logs.loading_log"
)}
></ha-skeleton-log>`
: html`<div class="waiting">
${this._i18n.localize(
"ui.panel.config.integrations.config_entry.live_log.waiting",
{ integration: this.params.name }
)}
</div>`
}
</ha-log-viewer>
${
@@ -101,6 +101,8 @@ export class DialogYamlIntegration extends LitElement {
ha-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;
--ha-dialog-header-white-space: normal;
--ha-dialog-header-title-height: auto;
}
`;
}
@@ -75,8 +75,16 @@ class HaConfigEntryDeviceRow extends LitElement {
);
const supportingText = [
area ? html`<span class="area">${area.name}</span>` : undefined,
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
entities.length
? bidiIsolate(
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
)
: undefined,
].filter(Boolean);
return html`<ha-list-item-button
@@ -114,18 +122,9 @@ class HaConfigEntryDeviceRow extends LitElement {
${computeDeviceNameDisplay(device, this.hass.localize)}
</div>
<span slot="supporting-text"
>${supportingText.join(" • ")}
${supportingText.length && entities.length ? " • " : nothing}
${
entities.length
? bidiIsolate(
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)
)
: nothing
}</span
>${supportingText.map(
(part, index) => html`${index ? " • " : nothing}${part}`
)}</span
>
${!this.narrow ? html`<ha-icon-next slot="end"></ha-icon-next>` : nothing}
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
@@ -389,6 +388,9 @@ class HaConfigEntryDeviceRow extends LitElement {
.disabled {
opacity: 0.5;
}
.area {
color: var(--primary-text-color);
}
:host([narrow]) ha-list-item-button {
--ha-row-item-padding-inline: 16px;
}
@@ -101,6 +101,9 @@ export interface ConfigEntryData {
subEntries: SubEntryData[];
}
// The AI settings show how to connect to the MCP server and its APIs.
const AI_SETTINGS_DOMAINS = ["mcp", "mcp_server"];
export const renderConfigEntryError = (
hass: HomeAssistant,
entry: ConfigEntry
@@ -396,6 +399,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.domain
);
const showAddEntry =
this._manifest?.integration_type !== "hardware" &&
(!this._manifest?.single_config_entry ||
(normalData.length === 0 && attentionData.length === 0));
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
@@ -689,6 +697,26 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
</div>
<div class="actions">
${
AI_SETTINGS_DOMAINS.includes(this.domain)
? html`<ha-button
.appearance=${showAddEntry ? "outlined" : "accent"}
href=${
// The demo routes with the hash, so it cannot link to a section.
__DEMO__ ? "/config/ai" : "/config/ai#mcp"
}
>
${this.hass.localize(
"ui.panel.config.integrations.integration_page.open_config_panel",
{
name: this.hass.localize(
"ui.panel.config.ai.caption"
),
}
)}
</ha-button>`
: nothing
}
${
canAddDevice
? html`
@@ -701,9 +729,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
: nothing
}
${
this._manifest?.integration_type !== "hardware" &&
(!this._manifest?.single_config_entry ||
(normalData.length === 0 && attentionData.length === 0))
showAddEntry
? html`<ha-button
.appearance=${canAddDevice ? "filled" : "accent"}
@click=${this._addIntegration}
+1 -1
View File
@@ -285,7 +285,7 @@ export class HaConfigLabels extends LitElement {
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize("ui.panel.config.labels.delete_selected")}
.label=${this.hass.localize("ui.common.delete")}
.disabled=${!this._selected.length}
@click=${this._deleteSelected}
></ha-assist-chip>
+14 -3
View File
@@ -37,6 +37,7 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/skeleton/ha-skeleton-log";
import { getSignedPath } from "../../../data/auth";
@@ -350,9 +351,13 @@ class ErrorLogCard extends LitElement {
)}
</ha-alert>`
: this._loadingState === "loading"
? html`<div>
${localize("ui.panel.config.logs.loading_log")}
</div>`
? // Lines kept while reconnecting stay visible, unless the search hides them all
this._ansiToHtmlElement?.hasLines &&
!(this.filter && this._noSearchResults)
? nothing
: html`<ha-skeleton-log
.label=${localize("ui.panel.config.logs.loading_log")}
></ha-skeleton-log>`
: this._loadingState === "empty"
? html`<div>
${
@@ -749,6 +754,8 @@ class ErrorLogCard extends LitElement {
// The backlog is sent again on every subscription
this._integrationLines = [];
this._ansiToHtmlElement?.clear();
// Show the skeleton again now the retained lines are gone
this.requestUpdate();
// Each subscription starts with the integration's backlog, which are
// merged in time order with the lines sent meanwhile once all arrived.
@@ -1106,6 +1113,10 @@ class ErrorLogCard extends LitElement {
overflow-wrap: break-word;
}
ha-skeleton-log {
--ha-skeleton-log-padding: var(--ha-space-4);
}
.error-log > div:hover {
background-color: var(--secondary-background-color);
}
@@ -461,9 +461,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
</ha-dropdown>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.delete_selected"
)}
.label=${this.hass.localize("ui.common.delete")}
.disabled=${!this._deletableSelected().length}
@click=${this._removeSelected}
></ha-assist-chip>
@@ -106,6 +106,10 @@ class HaConfigSectionNetwork extends LitElement {
margin-bottom: 24px;
max-width: 600px;
}
.discovery-card ha-list-item-button::part(headline),
.discovery-card ha-list-item-button::part(supporting-text) {
white-space: normal;
}
`;
}
@@ -73,6 +73,10 @@ const mountPartitionLabel = ({
return drive ? `${drive} — ${identity}, ${size}` : `${identity}, ${size}`;
};
// Supervisor accepts only ASCII letters, digits and underscores.
const mountNameFromLabel = (label?: string): string =>
(label ?? "").replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_");
const requiredFieldsFilled = (
schema: readonly HaFormSchema[],
data?: MountFormData
@@ -305,6 +309,8 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _originalType?: SupervisorMountType;
private _suggestedName?: string;
@state() private _diskIdentity?: string;
public connectedCallback(): void {
@@ -529,7 +535,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent) {
this._validationError = {};
this._validationWarning = {};
const previous = this._data;
this._data = ev.detail.value;
this._suggestName(previous);
if (this._data?.name && !/^\w+$/.test(this._data.name)) {
this._validationError.name = "invalid_name";
}
@@ -556,6 +564,32 @@ class ViewMountDialog extends DirtyStateProviderMixin<
);
}
// Name a new disk mount after the partition's volume label, unless the user
// typed a name of their own.
private _suggestName(previous?: MountFormData) {
const data = this._data;
if (data?.name !== previous?.name) {
this._suggestedName = undefined;
}
if (
this._existing ||
data?.type !== SupervisorMountType.DISK ||
(data.device === previous?.device &&
previous?.type === SupervisorMountType.DISK) ||
(data.name && data.name !== this._suggestedName)
) {
return;
}
const name = mountNameFromLabel(
this._partitions?.find((entry) => entry.partition.device === data.device)
?.partition.label
);
if (name !== (data.name ?? "")) {
data.name = name;
}
this._suggestedName = name;
}
private async _connectMount(ev) {
const progressButton = ev.target as HaProgressButton;
this._error = undefined;
+1 -1
View File
@@ -234,7 +234,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize("ui.panel.config.tag.delete_selected")}
.label=${this.hass.localize("ui.common.delete")}
.disabled=${!this._selected.length}
@click=${this._removeSelected}
></ha-assist-chip>
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/ha-switch";
@@ -43,6 +43,13 @@ class HaDebugConnectionRow extends LitElement {
setDebugConnection(debugConnection);
storeState(this.hass);
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { storage } from "../../../../common/decorators/storage";
import { setViewTransitionDisabled } from "../../../../common/util/view-transition";
@@ -41,6 +41,13 @@ class HaDebugDisableViewTransitionRow extends LitElement {
this._disabled = (ev.target as HaSwitch).checked;
setViewTransitionDisabled(this._disabled);
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -1,4 +1,4 @@
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { storage } from "../../../../common/decorators/storage";
@@ -42,6 +42,13 @@ class HaDebugSafeAreaToolRow extends LitElement {
private _checkedChanged(ev: Event) {
this._enabled = (ev.target as HaSwitch).checked;
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
+9 -1
View File
@@ -82,6 +82,9 @@ export class HaLogbook extends LitElement {
@property({ attribute: "show-more-link", type: Boolean })
public showMoreLink = true;
@property({ type: Number, attribute: "max-entries" })
public maxEntries?: number;
@state() private _logbookEntries?: LogbookEntry[];
@state() private _traceContexts: TraceContexts = {};
@@ -153,7 +156,7 @@ export class HaLogbook extends LitElement {
.graphColor=${this.graphColor}
.showCause=${this.showCause}
.nameDetail=${this.nameDetail}
.entries=${entries}
.entries=${this._limitEntries(entries, this.maxEntries)}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
.systemUserIds=${this._systemUserIds}
@@ -172,6 +175,11 @@ export class HaLogbook extends LitElement {
);
}
private _limitEntries = memoizeOne(
(entries: LogbookEntry[], maxEntries: number | undefined) =>
maxEntries ? entries.slice(0, maxEntries) : entries
);
private _filterEntriesByCause = memoizeOne(
(
entries: LogbookEntry[] | undefined,
+10 -13
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consume } from "../../../common/decorators/consume";
import {
consumeEntityState,
@@ -14,7 +13,7 @@ import {
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
computeAlertColor,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -121,12 +120,14 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
const active = isAlertActive(stateObj);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const color = computeAlertColor(
stateObj,
active
? (this._config.alert_color ?? "state")
: (this._config.clear_color ?? "none")
);
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const name = this._formatters.formatEntityName(stateObj, this._config.name);
@@ -143,10 +144,9 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
.type=${hasTileCardAction(this._config) ? "button" : "badge"}
.label=${showState && showName ? name : undefined}
.iconOnly=${!content}
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
class=${classMap({ alert: true, pulse })}
style=${styleMap({
"--ha-alert-color":
color && hasColor ? computeCssColor(color) : undefined,
"--ha-alert-color": color,
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
@@ -205,10 +205,7 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
);
background: var(--ha-alert-badge-background);
--ha-card-background: transparent;
--badge-color: var(--ha-alert-color);
}
ha-badge.alert.no-color {
--badge-color: var(--secondary-text-color);
--badge-color: var(--ha-alert-color, var(--secondary-text-color));
}
ha-badge.alert::before {
position: absolute;
+10 -15
View File
@@ -5,12 +5,11 @@ import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../../common/decorators/consume";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
computeAlertColor,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -120,12 +119,14 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
const active = isAlertActive(stateObj);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const color = computeAlertColor(
stateObj,
active
? (this._config.alert_color ?? "state")
: (this._config.clear_color ?? "none")
);
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
@@ -133,10 +134,9 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
class=${classMap({ pulse })}
style=${styleMap({
"--ha-alert-color":
color && hasColor ? computeCssColor(color) : undefined,
"--ha-alert-color": color,
"--ha-alert-static-opacity": pulse
? undefined
: "var(--ha-alert-pulse-opacity)",
@@ -211,12 +211,7 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
position: relative;
overflow: hidden;
height: 100%;
}
ha-card:not(.no-color) {
--tile-color: var(--ha-alert-color);
}
ha-card.no-color {
--tile-color: var(--secondary-text-color);
--tile-color: var(--ha-alert-color, var(--secondary-text-color));
}
ha-card::before {
position: absolute;
@@ -310,11 +310,11 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
position: relative;
}
#states > div:first-child > hui-section-row::part(divider) {
#states:first-child > div:first-child > hui-section-row::part(divider) {
display: none;
}
#states > div:first-child > hui-section-row::part(label) {
#states:first-child > div:first-child > hui-section-row::part(label) {
margin-top: 0;
}
@@ -401,7 +401,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
? "pointer"
: "initial",
})}
@click=${this._handleSeek}
@change=${this._handleSeek}
>
</ha-slider>
`
@@ -1,3 +1,4 @@
import { navigate } from "../../../common/navigate";
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@@ -13,8 +14,10 @@ import {
} from "../../../data/lovelace/config/types";
import { isStrategyView } from "../../../data/lovelace/config/view";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { getPanelTitleFromUrlPath, LOVELACE_PANEL } from "../../../data/panel";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import { SECTIONS_VIEW_LAYOUT } from "../views/const";
import { getViewType } from "../views/get-view-type";
import type { SuggestCardDialogParams } from "./card-editor/show-suggest-card-dialog";
@@ -144,7 +147,23 @@ export const addEntitiesToLovelaceView = async (
await saveConfig(hass!, viewUrlPath, newConfig);
} catch (_err: any) {
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
return;
}
const dashboardPath = viewUrlPath ?? LOVELACE_PANEL;
const viewPath = newConfig.views[viewIndex].path || viewIndex;
const dashboardTitle = getPanelTitleFromUrlPath(hass, dashboardPath);
showToast(element, {
message: dashboardTitle
? {
translationKey: "ui.panel.lovelace.add_entities.added",
args: { dashboard: dashboardTitle },
}
: { translationKey: "ui.panel.lovelace.add_entities.added_unnamed" },
action: {
text: { translationKey: "ui.panel.lovelace.add_entities.open" },
action: () => navigate(`/${dashboardPath}/${viewPath}`),
},
});
};
const view = config.views[viewIndex];
@@ -20,7 +20,6 @@ import {
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import type { DeviceSectionCategory } from "../../strategies/device/device-section-strategy";
import {
@@ -253,7 +252,6 @@ export class HuiDialogAddDeviceCard
addCards(this._params.lovelaceConfig, this._params.path, this._cardConfig)
);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
@@ -17,7 +17,6 @@ import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/vie
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import "../../sections/hui-section";
import { getViewType } from "../../views/get-view-type";
@@ -282,7 +281,6 @@ export class HuiDialogSuggestCard extends LitElement {
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
}
@@ -1,5 +1,4 @@
import { mdiAlertOutline, mdiTextShort } from "@mdi/js";
import { DEFAULT_ALERT_COLOR } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { contentLayoutSchema, displayedElementsSchema } from "./content-schema";
@@ -18,7 +17,11 @@ export const alertSchema = () =>
{
name: "clear_color",
selector: {
ui_color: { include_none: true, default_color: "none" },
ui_color: {
include_none: true,
include_state: true,
default_color: "none",
},
},
},
{
@@ -26,7 +29,8 @@ export const alertSchema = () =>
selector: {
ui_color: {
include_none: true,
default_color: DEFAULT_ALERT_COLOR,
include_state: true,
default_color: "state",
},
},
},
@@ -7,6 +7,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-svg-icon";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import { computeUnusedEntities } from "../../common/compute-unused-entities";
import {
computeCards,
@@ -111,6 +112,11 @@ export class HuiUnusedEntities extends LitElement {
this._selectedEntities = ev.detail.selectedEntities;
}
private _saveConfig = async (config: LovelaceConfig): Promise<void> => {
await this.lovelace.saveConfig(config);
showSaveSuccessToast(this, this.hass);
};
private _addToLovelaceView(): void {
const cardConfig = computeCards(this.hass, this._selectedEntities, {});
const sectionConfig = computeSection(this._selectedEntities, {});
@@ -118,7 +124,7 @@ export class HuiUnusedEntities extends LitElement {
if (this.lovelace.config.views.length === 1) {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
saveConfig: this._saveConfig,
path: ["views", 0],
entities: this._selectedEntities,
cardConfig,
@@ -132,7 +138,7 @@ export class HuiUnusedEntities extends LitElement {
viewSelectedCallback: (_urlPath, _selectedDashConfig, viewIndex) => {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
saveConfig: this._saveConfig,
path: ["views", viewIndex],
entities: this._selectedEntities,
cardConfig,
+2 -2
View File
@@ -319,7 +319,7 @@ export class LovelacePanel extends LitElement {
// Refreshing a YAML config can trigger an update event. We will ignore
// all update events while fetching the config and for 2 seconds after the config is back.
// We ignore because we already have the latest config.
if (this.lovelace && this.lovelace.mode === "yaml") {
if (confMode === "yaml") {
this._ignoreNextUpdateEvent = true;
}
@@ -367,7 +367,7 @@ export class LovelacePanel extends LitElement {
} finally {
this._loading = false;
// Ignore updates for another 2 seconds.
if (this.lovelace && this.lovelace.mode === "yaml") {
if (confMode === "yaml") {
setTimeout(() => {
this._ignoreNextUpdateEvent = false;
}, 2000);
@@ -92,16 +92,22 @@ export const filterUnavailableBatteryEntities = (
return states[entityId]?.state === "unavailable";
});
const computeBatteryTileCard = (
const computeMaintenanceTileCard = (
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entityId: string
): TileCardConfig => {
const entity = entities[entityId];
const deviceId = entity?.device_id;
const deviceId = entities[entityId]?.device_id;
if (!deviceId) {
return { type: "tile", entity: entityId, name: { type: "entity" } };
}
const isProblem = states[entityId]?.attributes?.device_class === "problem";
return {
type: "tile",
entity: entityId,
name: { type: deviceId ? "device" : "entity" },
name: isProblem
? [{ type: "device" }, { type: "entity" }]
: { type: "device" },
};
};
@@ -130,7 +136,9 @@ const processAreasForBattery = (
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(computeBatteryTileCard(hass.entities, entityId));
areaCards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
}
if (areaCards.length > 0) {
@@ -170,7 +178,9 @@ const processUnassignedEntities = (
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(computeBatteryTileCard(hass.entities, entityId));
cards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
}
return cards;
@@ -3,6 +3,7 @@ import {
mdiAlertOctagonOutline,
mdiAlertOutline,
mdiDatabaseExportOutline,
mdiShieldKeyOutline,
mdiWeb,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
@@ -26,6 +27,7 @@ import {
} from "../../../data/marketplace/websocket";
const RISKS = [
{ risk: "access", icon: mdiShieldKeyOutline },
{ risk: "internet", icon: mdiWeb },
{ risk: "data", icon: mdiDatabaseExportOutline },
{ risk: "stability", icon: mdiAlertOctagonOutline },
@@ -426,7 +426,7 @@ export class HaMarketplaceDashboard extends LitElement {
? html`<ha-assist-chip
slot="selection-bar"
.label=${this._i18n.localize(
"ui.panel.marketplace.dashboard.uninstall_selected"
"ui.panel.marketplace.common.uninstall"
)}
.disabled=${!this._selected.length || this._checkingSelection}
@click=${this._uninstallSelected}
@@ -1,3 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -12,11 +13,13 @@ import "../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../components/ha-checkbox";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-select";
import type {
HaSelectOption,
HaSelectSelectEvent,
} from "../../../components/ha-select";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import "../../../components/ha-label";
import {
DEFAULT_ROW_RENDERER_CONTENT,
type PickerComboBoxItem,
} from "../../../components/ha-picker-combo-box";
import "../../../components/ha-spinner";
import "../../../components/progress/ha-progress-bar";
@@ -48,8 +51,14 @@ import { repositoryAuthors } from "../tools/authors";
import { handleGitHubRateLimited } from "../tools/connect-github";
import { installBlockedReason } from "../tools/install-blocked-reason";
import { generateFrontendResourceURL } from "../tools/frontend-resource";
import type { ValueChangedEvent } from "../../../types";
import type { MarketplaceInstallDialogParams } from "./show-dialog-marketplace-install";
interface ReleaseComboBoxItem extends PickerComboBoxItem {
installed: boolean;
latest: boolean;
}
@customElement("dialog-marketplace-install")
export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDialogParams>(
LitElement
@@ -432,14 +441,17 @@ export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDial
? this._i18n.localize(
"ui.panel.marketplace.dialog_install.no_releases"
)
: html`<ha-select
: html`<ha-generic-picker
.label=${this._i18n.localize(
"ui.panel.marketplace.dialog_install.release"
)}
.value=${this._selectedVersion}
.options=${this._releaseOptions(this._releases)}
@selected=${this._versionChanged}
></ha-select>`
.getItems=${this._getReleaseItems}
.rowRenderer=${this._releaseRowRenderer}
hide-clear-icon
no-sort
@value-changed=${this._versionChanged}
></ha-generic-picker>`
}
</div>`;
}
@@ -633,26 +645,73 @@ export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDial
}
}
private _releaseOptions(releases: MarketplaceRelease[]): HaSelectOption[] {
return releases.map((release) => ({
value: release.tag,
label: release.tag,
secondary: [
release.prerelease
? this._i18n.localize(
"ui.panel.marketplace.dialog_install.pre_release"
)
: undefined,
relativeTime(new Date(release.published_at), this._i18n.locale),
release.name && release.name !== release.tag ? release.name : undefined,
]
.filter(Boolean)
.join(" · "),
}));
}
// GitHub lists the newest first, which is the order to keep
private _releaseItems = memoizeOne(
(
releases: MarketplaceRelease[],
repository: RepositoryInfo,
localize: ContextType<typeof internationalizationContext>["localize"],
locale: ContextType<typeof internationalizationContext>["locale"]
): ReleaseComboBoxItem[] =>
releases.map((release) => ({
id: release.tag,
primary: release.tag,
installed:
repository.installed && release.tag === repository.installed_version,
latest: release.tag === repository.available_version,
secondary: [
release.prerelease
? localize("ui.panel.marketplace.dialog_install.pre_release")
: undefined,
relativeTime(new Date(release.published_at), locale),
release.name && release.name !== release.tag
? release.name
: undefined,
]
.filter(Boolean)
.join(" · "),
}))
);
private _versionChanged(ev: HaSelectSelectEvent<string, true>) {
this._selectedVersion = ev.detail.value;
private _getReleaseItems = () =>
this._releases && this._repository
? this._releaseItems(
this._releases,
this._repository,
this._i18n.localize,
this._i18n.locale
)
: [];
private _releaseRowRenderer: RenderItemFunction<ReleaseComboBoxItem> = (
item
) =>
html`<ha-combo-box-item>
${DEFAULT_ROW_RENDERER_CONTENT(item)}
${
item.installed
? html`<ha-label slot="end" dense>
${this._i18n.localize(
"ui.panel.marketplace.dialog_install.release_installed"
)}
</ha-label>`
: nothing
}
${
item.latest
? html`<ha-label slot="end" dense>
${this._i18n.localize(
"ui.panel.marketplace.dialog_install.release_latest"
)}
</ha-label>`
: nothing
}
</ha-combo-box-item>`;
private _versionChanged(ev: ValueChangedEvent<string | undefined>) {
if (ev.detail.value) {
this._selectedVersion = ev.detail.value;
}
}
static get styles(): CSSResultGroup {
@@ -667,9 +726,6 @@ export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDial
.content {
padding: var(--ha-space-6);
}
.versions ha-select {
display: block;
}
pre {
white-space: pre-line;
user-select: all;
+26
View File
@@ -9,6 +9,7 @@ import {
getDefaultWhiteList,
safeAttrValue,
} from "xss";
import { URL_ATTRIBUTES } from "../common/dom/rewrite-html-urls";
let whiteListNormal: IWhiteList | undefined;
let whiteListSvg: IWhiteList | undefined;
@@ -42,6 +43,23 @@ const sanitizeSrcset = (value: string): string => {
: "";
};
// Keep a relative address xss would empty as a path below the current one.
// Returns the attribute to render, or "" to drop it.
const sanitizeRelativeUrl = (tag: string, name: string, value: string) => {
const decoded = friendlyAttrValue(value).replace(/&amp;/g, "&").trim();
if (
!decoded ||
/^[a-z][a-z\d+.-]*:/i.test(decoded) ||
friendlyAttrValue(decoded) !== decoded
) {
return "";
}
const path = `./${decoded}`;
return safeAttrValue(tag, name, path, cssFilter)
? `${name}="${escapeAttrValue(path.replace(/&/g, "&amp;"))}"`
: "";
};
const renderMarkdown = async (
content: string,
markedOptions: MarkedOptions,
@@ -49,6 +67,7 @@ const renderMarkdown = async (
// Do not allow SVG on untrusted content, it allows XSS.
allowSvg?: boolean;
allowDataUrl?: boolean;
allowRelativeUrls?: boolean;
} = {}
): Promise<string[]> => {
if (!whiteListNormal) {
@@ -140,6 +159,13 @@ const renderMarkdown = async (
) {
return `href="${value}"`;
}
if (
hassOptions.allowRelativeUrls &&
URL_ATTRIBUTES.some(([t, a]) => t === tag && a === name) &&
safeAttrValue(tag, name, value, cssFilter) === ""
) {
return sanitizeRelativeUrl(tag, name, value);
}
return undefined;
},
})
@@ -0,0 +1,10 @@
// Install the Intl.PluralRules polyfill together with its locale data.
// FormatJS replaces Intl.PluralRules as soon as polyfill-force runs, and a
// data-less polyfill throws from select(). Keeping both imports in one module
// closes that gap for every caller, including the should-polyfill probes
// that other entry bundles run.
import "@formatjs/intl-pluralrules/polyfill-force";
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
import "@formatjs/intl-pluralrules/locale-data/en";
+10 -15
View File
@@ -14,8 +14,6 @@ import {
polyfillTimeZoneData,
} from "./locale-data-polyfill";
let polyfilled = false;
const _polyfillTimeZoneData = polyfillTimeZoneData;
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
@@ -47,10 +45,6 @@ const setDefaultTimeZone = () => {
};
const polyfillIntl = async () => {
if (polyfilled) {
return;
}
polyfilled = true;
const locale = getLocalLanguage();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillGetCanonicalLocales()) {
@@ -79,14 +73,10 @@ const polyfillIntl = async () => {
polyfills.push(import("@formatjs/intl-numberformat/polyfill-force"));
}
if (shouldPolyfillPluralRules(locale)) {
polyfills.push(
import("@formatjs/intl-pluralrules/polyfill-force").then(
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
() => import("@formatjs/intl-pluralrules/locale-data/en")
)
);
// The polyfill and its English data are one chunk, so Intl.PluralRules
// is never installed without data. Its should-polyfill probe calls
// select(), which throws on a data-less polyfill.
polyfills.push(import("./intl-pluralrules"));
}
if (shouldPolyfillRelativeTimeFormat(locale)) {
polyfills.push(import("@formatjs/intl-relativetimeformat/polyfill-force"));
@@ -100,4 +90,9 @@ const polyfillIntl = async () => {
);
};
await polyfillIntl();
// Each legacy entry bundle (core, app, authorize, onboarding) carries its own
// copy of this module, and the bundles load in parallel. A module-level flag
// only stops re-runs within one bundle, so a second bundle could probe
// Intl.* while the first was still loading locale data. Share one promise
// per page instead, so every bundle waits for the same run.
await (globalThis.__haIntlPolyfill ??= polyfillIntl());
+12 -11
View File
@@ -17,6 +17,7 @@
"climate": "Climate",
"home": "Overview",
"maintenance": "Maintenance",
"marketplace": "Marketplace",
"notfound": "Page not found"
},
"state": {
@@ -857,7 +858,6 @@
},
"mode_composed": "Composed",
"mode_custom": "Custom",
"add": "Add",
"search": "Search name",
"custom_name": "Custom name",
"no_more_parts": "Nothing left to add",
@@ -944,7 +944,6 @@
"collapse": "Collapse"
},
"entity-type-picker": {
"add": "Add type",
"no_types": "No types available"
},
"subpage-data-table": {
@@ -2970,7 +2969,7 @@
},
"marketplace": {
"main": "Marketplace",
"secondary": "Install integrations, dashboard cards, themes, and templates shared by the community"
"secondary": "Install community integrations, dashboard content, themes, and templates"
},
"dashboards": {
"main": "Dashboards",
@@ -3528,7 +3527,6 @@
"introduction2": "Go to the area, device, or entity you want to add a label to, and press the edit button to assign labels to them.",
"confirm_delete_title": "Delete label?",
"confirm_delete": "Are you sure you want to delete label {label}? It will be removed from all areas, devices, and entities.",
"delete_selected": "Delete selected",
"confirm_delete_selected_title": "Delete selected labels?",
"confirm_delete_selected": "{count, plural,\n one {The selected label will be removed from all areas, devices, and entities.}\n other {The {count} selected labels will be removed from all areas, devices, and entities.}\n}",
"delete_failed_title": "Delete failed",
@@ -4188,7 +4186,6 @@
"create_automation": "Create automation",
"confirm_delete_title": "Delete tag?",
"confirm_delete": "The tag ''{tag}'' will be permanently deleted.",
"delete_selected": "Delete selected",
"confirm_delete_selected_title": "Delete selected tags?",
"confirm_delete_selected": "{count, plural,\n one {The selected tag will be permanently deleted.}\n other {The {count} selected tags will be permanently deleted.}\n}",
"delete_failed_title": "Delete failed",
@@ -5208,7 +5205,6 @@
},
"confirm_delete_title": "Delete {dashboard_title}?",
"confirm_delete_text": "This dashboard will be permanently deleted.",
"delete_selected": "Delete selected",
"confirm_delete_selected_title": "Delete selected dashboards?",
"confirm_delete_selected": "{count, plural,\n one {The selected dashboard will be permanently deleted.}\n other {The {count} selected dashboards will be permanently deleted.}\n}",
"confirm_delete_selected_partly": "You can only delete {deletable} of the {selected} dashboards. The others are built-in or configured in YAML.",
@@ -6726,7 +6722,6 @@
"blueprint_in_use_view_script": "View scripts",
"confirm_delete_title": "Delete blueprint?",
"confirm_delete_text": "{name} will be permanently deleted.",
"delete_selected": "Delete selected",
"confirm_delete_selected_title": "Delete selected blueprints?",
"confirm_delete_selected": "{count, plural,\n one {The selected blueprint will be permanently deleted.}\n other {The {count} selected blueprints will be permanently deleted.}\n}",
"confirm_delete_selected_partly": "You can only delete {deletable} of the {selected} blueprints. The others are in use by automations or scripts.",
@@ -7807,7 +7802,8 @@
"add_hardware": "Add hardware",
"add_entity": "[%key:ui::panel::config::integrations::integration_page::add_entry%]",
"add_system": "[%key:ui::panel::config::integrations::integration_page::add_entry%]",
"add_virtual": "[%key:ui::panel::config::integrations::integration_page::add_entry%]"
"add_virtual": "[%key:ui::panel::config::integrations::integration_page::add_entry%]",
"open_config_panel": "{name} settings"
},
"config_entry": {
"application_credentials": {
@@ -10225,7 +10221,10 @@
"add_entities": {
"yaml_unsupported": "You cannot use this function when using your dashboard in YAML mode.",
"generated_unsupported": "You can only use this function when you have taken control of your dashboard.",
"saving_failed": "Saving dashboard configuration failed."
"saving_failed": "Saving dashboard configuration failed.",
"added": "Added to {dashboard}",
"added_unnamed": "Added to the dashboard",
"open": "Open"
},
"views": {
"unnamed_view": "Unnamed view",
@@ -12966,6 +12965,7 @@
"intro": "Marketplace items are made by community contributors and are not reviewed by the Home\u00a0Assistant project. Treat them like plugins for any other software.",
"risks_title": "Anything you install can",
"risks": {
"access": "Access and control everything in Home\u00a0Assistant",
"internet": "Connect to the internet",
"data": "Share your data with others",
"stability": "Slow down or crash Home\u00a0Assistant"
@@ -13001,8 +13001,7 @@
"empty_header": "Nothing in the Marketplace yet",
"empty_text": "The Marketplace gets its list from the community catalog, which could not be reached yet. The list shows up here once it loads.",
"no_data": "No repositories matching search and filters",
"repository_not_found": "Repository not found",
"uninstall_selected": "Uninstall selected"
"repository_not_found": "Repository not found"
},
"entry": {
"disabled": "The Marketplace is disabled. Enable it on the integration page to use it.",
@@ -13068,6 +13067,8 @@
"already_installing": "This is already being installed, hold on.",
"install_failed": "Installation failed. Check the Home Assistant logs for details.",
"pre_release": "Pre-release",
"release_installed": "Installed",
"release_latest": "Latest",
"install_intro": "This installs {name} version {version} in Home Assistant.",
"update_intro": "This updates {name} from version {installed} to {version}.",
"reinstall_intro": "This installs version {version} of {name} again.",
+2
View File
@@ -41,6 +41,8 @@ declare global {
var __HASS_URL__: string;
// Read by intl-polyfill before a polyfill replaces Intl.DateTimeFormat
var __haNativeTimeZone: string | undefined;
// Shared by every legacy entry bundle so the Intl polyfills load once
var __haIntlPolyfill: Promise<void> | undefined;
// Handshake between the app entry and the extra modules in index.html
var __haScopedRegistryReady: boolean | undefined;
var __haLoadExtraModules: (() => void) | undefined;
@@ -0,0 +1,52 @@
import type { ReactiveControllerHost } from "@lit/reactive-element/reactive-controller";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MediaProgressController } from "../../../src/common/controllers/media-progress-controller";
import type { HaSlider } from "../../../src/components/ha-slider";
import type { MediaPlayerEntity } from "../../../src/data/media-player";
describe("MediaProgressController", () => {
let dragging: boolean;
let slider: HaSlider;
let controller: MediaProgressController;
beforeEach(() => {
dragging = false;
slider = {
value: 0,
customStates: {
has: (state: string) => state === "dragging" && dragging,
},
// Chromium < 125 throws on matches(":state(...)").
matches: (selector: string) => {
if (selector.includes(":state(")) {
throw new DOMException(`'${selector}' is not a valid selector.`);
}
return false;
},
} as unknown as HaSlider;
controller = new MediaProgressController(
{ addController: vi.fn() } as unknown as ReactiveControllerHost,
{
getStateObj: () =>
({
state: "paused",
attributes: { media_duration: 300, media_position: 42 },
}) as unknown as MediaPlayerEntity,
getSlider: () => slider,
}
);
});
it("writes the position where :state() is not supported", () => {
controller.hostUpdate();
controller.hostUpdated();
expect(slider.value).toBe(42);
});
it("leaves the slider alone while it is dragged", () => {
dragging = true;
controller.hostUpdate();
controller.hostUpdated();
expect(slider.value).toBe(0);
});
});
@@ -9,6 +9,7 @@ import {
computeAttributeNameDisplay,
} from "../../../src/common/entity/compute_attribute_display";
import type { FrontendLocaleData } from "../../../src/data/translation";
import { TimeZone } from "../../../src/data/translation";
import type { HomeAssistant } from "../../../src/types";
export const localizeMock = (key: string) => {
@@ -112,6 +113,19 @@ describe("computeAttributeValueDisplay", () => {
expect(result).toBe("October 10, 2023");
});
it("should not shift date string to another day in a server time zone", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
{ ...localeMock, time_zone: TimeZone.server },
{ ...configMock, time_zone: "America/New_York" },
entitiesMock,
"attribute",
"2023-10-10"
);
expect(result).toBe("October 10, 2023");
});
it("should return formatted datetime for timestamp", () => {
const result = computeAttributeValueDisplay(
localizeMock,
@@ -0,0 +1,184 @@
import { ContextProvider } from "@lit/context";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import "../../src/components/ha-areas-floors-display-editor";
import type {
AreasFloorsDisplayValue,
HaAreasFloorsDisplayEditor,
} from "../../src/components/ha-areas-floors-display-editor";
import type { HaItemDisplayEditor } from "../../src/components/ha-items-display-editor";
import {
areasContext,
floorsContext,
internationalizationContext,
} from "../../src/data/context";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../src/types";
beforeAll(() => {
// jsdom's ElementInternals lacks the validity API used by the webawesome
// buttons that render inside the items editor.
const internalsProto = window.ElementInternals.prototype as any;
internalsProto.setValidity = vi.fn();
internalsProto.setFormValue = vi.fn();
Object.defineProperty(internalsProto, "validity", {
get: () => ({ valid: true }),
configurable: true,
});
});
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
const UNASSIGNED_FLOOR = "__unassigned__";
const FLOORS = {
floor_a: { floor_id: "floor_a", name: "A", level: 0 },
floor_b: { floor_id: "floor_b", name: "B", level: 1 },
} as unknown as HomeAssistant["floors"];
const area = (areaId: string, floorId: string | null) => ({
area_id: areaId,
name: areaId,
floor_id: floorId,
icon: null,
});
const AREAS = Object.fromEntries(
[
area("a1", "floor_a"),
area("a2", "floor_a"),
area("a3", "floor_a"),
area("b1", "floor_b"),
area("b2", "floor_b"),
area("b3", "floor_b"),
area("u1", null),
area("u2", null),
].map((entry) => [entry.area_id, entry])
) as unknown as HomeAssistant["areas"];
describe("ha-areas-floors-display-editor", () => {
let host: HTMLDivElement | undefined;
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
host?.remove();
host = undefined;
vi.unstubAllGlobals();
});
const mount = async (
value: AreasFloorsDisplayValue
): Promise<HaAreasFloorsDisplayEditor> => {
host = document.createElement("div");
document.body.appendChild(host);
new ContextProvider(host, {
context: internationalizationContext,
initialValue: {
localize: (key: string) => key,
} as unknown as HomeAssistantInternationalization,
});
new ContextProvider(host, { context: areasContext, initialValue: AREAS });
new ContextProvider(host, { context: floorsContext, initialValue: FLOORS });
const el = document.createElement(
"ha-areas-floors-display-editor"
) as HaAreasFloorsDisplayEditor;
el.value = value;
host.appendChild(el);
await el.updateComplete;
return el;
};
// Drags the first visible area of a floor below the second one and returns
// the configuration the editor emits.
const reorderFirstTwo = async (
el: HaAreasFloorsDisplayEditor,
floorId: string
): Promise<AreasFloorsDisplayValue> => {
const editor = Array.from(
el.shadowRoot!.querySelectorAll("ha-items-display-editor")
).find(
(child) =>
(child as HaItemDisplayEditor & { floorId: string }).floorId === floorId
)!;
await editor.updateComplete;
const emitted: AreasFloorsDisplayValue[] = [];
el.addEventListener(
"value-changed",
(ev) => emitted.push((ev as CustomEvent).detail.value),
{ once: true }
);
editor.shadowRoot!.querySelector("ha-sortable")!.dispatchEvent(
new CustomEvent("item-moved", {
detail: { oldIndex: 0, newIndex: 1 },
bubbles: true,
composed: true,
})
);
expect(emitted).toHaveLength(1);
return emitted[0];
};
it.each([
{
name: "hidden area on another floor",
floorId: "floor_a",
value: {
areas_display: { hidden: ["b1"], order: ["b3", "b2"] },
},
expected: {
hidden: ["b1"],
order: ["a2", "a1", "a3", "b3", "b2"],
},
},
{
name: "hidden areas on the reordered and another floor",
floorId: "floor_a",
value: { areas_display: { hidden: ["a3", "b1"] } },
expected: { hidden: ["a3", "b1"], order: ["a2", "a1"] },
},
{
name: "reordering areas without a floor",
floorId: UNASSIGNED_FLOOR,
value: { areas_display: { hidden: ["b1"] } },
expected: { hidden: ["b1"], order: ["u2", "u1"] },
},
{
name: "no hidden areas",
floorId: "floor_b",
value: {},
expected: { order: ["b2", "b1", "b3"] },
},
])(
"keeps hidden areas unchanged when reordering: $name",
async ({ floorId, value, expected }) => {
const el = await mount(value);
const first = await reorderFirstTwo(el, floorId);
expect(first.areas_display).toEqual(expected);
// Saving and reordering again must not grow the hidden list
el.value = first;
await el.updateComplete;
const second = await reorderFirstTwo(el, floorId);
expect(second.areas_display?.hidden).toEqual(expected.hidden);
}
);
});
+24 -17
View File
@@ -300,26 +300,33 @@ export async function createOwner(page: Page) {
.click();
}
export async function completeCoreConfig(page: Page) {
export async function completeCoreConfig(
page: Page,
detectedUnitSystem?: "metric" | "us_customary"
) {
const location = page.locator("onboarding-location");
await expect(location).toBeAttached({ timeout: PANEL_TIMEOUT });
await location.evaluate((element, locationData) => {
element.dispatchEvent(
new CustomEvent("value-changed", {
bubbles: true,
composed: true,
detail: {
value: {
location: [locationData.latitude, locationData.longitude],
country: locationData.country,
elevation: String(locationData.elevation),
timezone: locationData.timeZone,
currency: locationData.currency,
await location.evaluate(
(element, { locationData, unitSystem }) => {
element.dispatchEvent(
new CustomEvent("value-changed", {
bubbles: true,
composed: true,
detail: {
value: {
location: [locationData.latitude, locationData.longitude],
country: locationData.country,
elevation: String(locationData.elevation),
timezone: locationData.timeZone,
currency: locationData.currency,
unit_system: unitSystem,
},
},
},
})
);
}, onboardingData.location);
})
);
},
{ locationData: onboardingData.location, unitSystem: detectedUnitSystem }
);
}
export async function completeAnalytics(page: Page) {
+22
View File
@@ -155,6 +155,28 @@ test("preserves empty client state through onboarding", async ({
expectNoPageErrors(errors);
});
for (const unitSystem of ["metric", "us_customary"] as const) {
test(`keeps the detected ${unitSystem} unit system`, async ({
page,
baseURL,
}) => {
const errors = trackPageErrors(page);
const calls = await setupOnboardingMocks(page);
await openOnboarding(page, baseURL!);
await createOwner(page);
await completeCoreConfig(page, unitSystem);
await expect.poll(() => calls.coreConfigCompleted).toBe(true);
expect(calls.coreConfig).toMatchObject({
type: "config/core/update",
unit_system: unitSystem,
country: onboardingData.location.country,
});
expectNoPageErrors(errors);
});
}
for (const { name, resume } of [
{
name: "saved tokens",
@@ -9,6 +9,7 @@ vi.mock("../../../../src/components/input/ha-input-search", () => ({}));
vi.mock("../../../../src/components/ha-button", () => ({}));
vi.mock("../../../../src/components/ha-dialog", () => ({}));
vi.mock("../../../../src/components/ha-dialog-footer", () => ({}));
vi.mock("../../../../src/components/skeleton/ha-skeleton-log", () => ({}));
vi.mock("../../../../src/components/ha-log-viewer", () => {
class HaLogViewerStub extends HTMLElement {
public lines: string[] = [];
@@ -2,8 +2,13 @@ import { describe, expect, it } from "vitest";
import {
filterLowBatteryEntities,
filterProblemEntities,
MaintenanceViewStrategy,
} from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import { mockEntity } from "../../../common/entity/context/context-mock";
import {
mockDevice,
mockEntity,
} from "../../../common/entity/context/context-mock";
import type { LovelaceSectionConfig } from "../../../../src/data/lovelace/config/section";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
describe("filterLowBatteryEntities", () => {
@@ -209,3 +214,70 @@ describe("filterProblemEntities", () => {
).toEqual(["binary_sensor.has_problem"]);
});
});
describe("MaintenanceViewStrategy.generate", () => {
it("names problem tiles after the device and the entity", async () => {
const hass = createMockHass(
{
"sensor.microwave_battery": createMockEntityState(
"sensor.microwave_battery",
"80",
{ device_class: "battery" }
),
"binary_sensor.microwave_overheating": createMockEntityState(
"binary_sensor.microwave_overheating",
"off",
{ device_class: "problem" }
),
"binary_sensor.standalone_problem": createMockEntityState(
"binary_sensor.standalone_problem",
"off",
{ device_class: "problem" }
),
},
{
devices: {
microwave: mockDevice({ id: "microwave", name: "Microwave" }),
},
entities: {
"sensor.microwave_battery": mockEntity({
entity_id: "sensor.microwave_battery",
device_id: "microwave",
}),
"binary_sensor.microwave_overheating": mockEntity({
entity_id: "binary_sensor.microwave_overheating",
device_id: "microwave",
}),
"binary_sensor.standalone_problem": mockEntity({
entity_id: "binary_sensor.standalone_problem",
}),
},
}
);
const view = await MaintenanceViewStrategy.generate(
{ type: "maintenance" },
hass
);
const section = view.sections?.[0] as LovelaceSectionConfig;
expect(section.cards?.slice(1)).toEqual([
{
type: "tile",
entity: "sensor.microwave_battery",
name: { type: "device" },
},
{
type: "tile",
entity: "binary_sensor.microwave_overheating",
name: [{ type: "device" }, { type: "entity" }],
},
{
type: "tile",
entity: "binary_sensor.standalone_problem",
name: { type: "entity" },
},
]);
});
});
+17 -6
View File
@@ -48,7 +48,16 @@ vi.mock("../../../src/components/ha-dialog", async () => {
vi.mock("../../../src/components/ha-dialog-footer", () =>
stubElement("ha-dialog-footer")
);
vi.mock("../../../src/components/ha-select", () => stubElement("ha-select"));
vi.mock("../../../src/components/ha-combo-box-item", () =>
stubElement("ha-combo-box-item")
);
vi.mock("../../../src/components/ha-generic-picker", () =>
stubElement("ha-generic-picker")
);
vi.mock("../../../src/components/ha-label", () => stubElement("ha-label"));
vi.mock("../../../src/components/ha-picker-combo-box", () => ({
DEFAULT_ROW_RENDERER_CONTENT: () => "",
}));
vi.mock("../../../src/components/ha-spinner", () => stubElement("ha-spinner"));
vi.mock("../../../src/components/progress/ha-progress-bar", () =>
stubElement("ha-progress-bar")
@@ -260,7 +269,7 @@ describe("dialog-marketplace-install", () => {
// Where the files land is not something to decide an installation on
expect(root.textContent).not.toContain("/config/custom_components");
// Choosing another version is a menu entry of its own
expect(root.querySelector("ha-select")).toBeNull();
expect(root.querySelector("ha-generic-picker")).toBeNull();
});
it("reinstalls the installed version, not the newest", async () => {
@@ -431,8 +440,10 @@ describe("dialog-marketplace-install", () => {
await settle(dialog);
expect(
getInternals(dialog.shadowRoot!.querySelector("ha-select")!).options
).toEqual([expect.objectContaining({ value: "v2" })]);
getInternals(
dialog.shadowRoot!.querySelector("ha-generic-picker")!
).getItems()
).toEqual([expect.objectContaining({ id: "v2" })]);
});
it("tells a failed release list once, next to the versions with a retry", async () => {
@@ -514,9 +525,9 @@ describe("dialog-marketplace-install", () => {
await settle(dialog);
dialog
.shadowRoot!.querySelector("ha-select")!
.shadowRoot!.querySelector("ha-generic-picker")!
.dispatchEvent(
new CustomEvent("selected", { detail: { value: "1.0.0" } })
new CustomEvent("value-changed", { detail: { value: "1.0.0" } })
);
await settle(dialog);
+31 -2
View File
@@ -10,6 +10,16 @@ import type { Api } from "../../../src/resources/markdown-worker";
vi.mock("comlink", () => ({ expose: vi.fn() }));
// The worker exposes its API once, when it is first imported
let workerApi: Api | undefined;
const workerRenderMarkdown = async () => {
if (!workerApi) {
await import("../../../src/resources/markdown-worker");
workerApi = vi.mocked(expose).mock.calls[0][0] as Api;
}
return workerApi.renderMarkdown;
};
const repository = {
id: "42",
full_name: "owner/repo",
@@ -373,8 +383,7 @@ describe("rewriteHtmlUrls with the README of a repository", () => {
});
it("shows the dark logo of a README from the repository", async () => {
await import("../../../src/resources/markdown-worker");
const { renderMarkdown } = vi.mocked(expose).mock.calls[0][0] as Api;
const renderMarkdown = await workerRenderMarkdown();
const html = (
await renderMarkdown(
[
@@ -400,4 +409,24 @@ describe("rewriteHtmlUrls with the README of a repository", () => {
`${raw}/images/logo-light.png`
);
});
it("shows images and links with a plain relative path", async () => {
const renderMarkdown = await workerRenderMarkdown();
const html = (
await renderMarkdown(
"![Logo](images/logo.png) [Setup](docs/setup.md)",
{},
{ allowRelativeUrls: true }
)
).join("");
const template = document.createElement("template");
template.innerHTML = rewriteHtmlUrls(html, toReadme);
expect(template.content.querySelector("img")?.getAttribute("src")).toBe(
`${raw}/images/logo.png`
);
expect(template.content.querySelector("a")?.getAttribute("href")).toBe(
"https://github.com/owner/repo/blob/v1.0.0/docs/setup.md"
);
});
});
+91
View File
@@ -174,6 +174,97 @@ describe("renderMarkdown", () => {
});
});
describe("relative addresses", () => {
const renderImageSrc = async (src: string, allowRelativeUrls: boolean) =>
parse(
(
await renderMarkdown(`<img src="${src}">`, {}, { allowRelativeUrls })
).join("")
)
.querySelector("img")
?.getAttribute("src");
it("keeps a plain relative path for a caller that rewrites it", async () => {
expect(await renderImageSrc("images/logo.png", true)).toBe(
"./images/logo.png"
);
});
it("keeps a padded relative path without the spaces", async () => {
expect(await renderImageSrc("&#32;images/logo.png&#32;", true)).toBe(
"./images/logo.png"
);
});
it("keeps the query string of a relative path", async () => {
expect(await renderImageSrc("images/logo.png?v=1&amp;size=2", true)).toBe(
"./images/logo.png?v=1&size=2"
);
});
it("keeps a quote in a relative path", async () => {
expect(await renderImageSrc("images/a&quot;b.png", true)).toBe(
'./images/a"b.png'
);
});
it("drops a relative path with entities left after decoding", async () => {
expect(await renderImageSrc("&#38;#105;mages/logo.png", true)).toBeNull();
});
it("keeps an allowed data link as it is", async () => {
const html = (
await renderMarkdown(
'<a href="data:text/plain,hello" download="hello.txt">Hello</a>',
{},
{ allowDataUrl: true, allowRelativeUrls: true }
)
).join("");
expect(parse(html).querySelector("a")?.getAttribute("href")).toBe(
"data:text/plain,hello"
);
});
it("empties a plain relative path otherwise", async () => {
expect(await renderImageSrc("images/logo.png", false)).toBe("");
});
it("keeps an address xss allows as it is", async () => {
expect(await renderImageSrc("https://example.com/a.png", true)).toBe(
"https://example.com/a.png"
);
});
it.each([
// eslint-disable-next-line no-script-url
"javascript:alert(1)",
"&#106;avascript:alert(1)",
"data:text/html,x",
"HTTPS://example.com/a.png",
"&#0000106avascript:alert(1)",
])("drops an address with a scheme: %s", async (src) => {
expect(await renderImageSrc(src, true)).toBeNull();
});
it("drops an empty address", async () => {
expect(await renderImageSrc("", true)).toBeNull();
});
it("keeps relative addresses only for links and images", async () => {
const html = (
await renderMarkdown(
'<a href="docs/setup.md">Setup</a><video src="clip.mp4"></video>',
{},
{ allowRelativeUrls: true }
)
).join("");
expect(parse(html).querySelector("a")?.getAttribute("href")).toBe(
"./docs/setup.md"
);
expect(parse(html).querySelector("video")?.getAttribute("src")).toBe("");
});
});
describe.each(["audio", "video"])("source src in %s", (tag) => {
const renderSrc = async (src: string) =>
parse(await render(`<${tag} controls><source src="${src}"></${tag}>`))