mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-03 19:44:41 +00:00
Compare commits
38
Commits
statistic-dialog
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d08d26f6 | ||
|
|
aa06e8ce25 | ||
|
|
122877cdf5 | ||
|
|
a103eac243 | ||
|
|
a9a8a9aad9 | ||
|
|
35a054c1ba | ||
|
|
960e99ed66 | ||
|
|
232375befb | ||
|
|
91ee29a2e6 | ||
|
|
a6dc5d0008 | ||
|
|
7a66de362e | ||
|
|
36500e5b19 | ||
|
|
df88136147 | ||
|
|
2f1d738c2c | ||
|
|
bfeb26b47d | ||
|
|
8de5bfee89 | ||
|
|
1efc7f8a95 | ||
|
|
9e0d38fba8 | ||
|
|
2932f8f528 | ||
|
|
6a0902d18c | ||
|
|
4424f98eb6 | ||
|
|
ea13594fb0 | ||
|
|
ce726a8a3f | ||
|
|
d67479da6d | ||
|
|
275e30879c | ||
|
|
7b53005865 | ||
|
|
94950fcacd | ||
|
|
4214fe46c6 | ||
|
|
d6325eef7d | ||
|
|
12e722f122 | ||
|
|
83eb3ca13a | ||
|
|
0532b1f5f6 | ||
|
|
970afb588d | ||
|
|
23137ce6d2 | ||
|
|
6b022d63e8 | ||
|
|
d3c6e132b7 | ||
|
|
c08b261d4b | ||
|
|
0721b22036 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: *
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
export const embedHassContext = createContext<HomeAssistant>("embedHass");
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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!);
|
||||
};
|
||||
@@ -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("")}}`
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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><ha-embed-provider></code> holds the mocked backend and
|
||||
its entities.
|
||||
</li>
|
||||
<li>
|
||||
<code><ha-embed-card></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><script type="module" src="https://demo.home-assistant.io/embed.js"></script></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><ha-embed-provider>
|
||||
<script type="text/yaml">
|
||||
- entity_id: light.kitchen
|
||||
state: "on"
|
||||
attributes:
|
||||
friendly_name: Kitchen
|
||||
supported_color_modes: [brightness]
|
||||
color_mode: brightness
|
||||
brightness: 180
|
||||
</script>
|
||||
<ha-embed-card>
|
||||
<script type="text/yaml">
|
||||
type: tile
|
||||
entity: light.kitchen
|
||||
tap_action:
|
||||
action: toggle
|
||||
features:
|
||||
- type: light-brightness
|
||||
</script>
|
||||
</ha-embed-card>
|
||||
</ha-embed-provider></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><ha-embed-provider id="living-room">…</ha-embed-provider>
|
||||
|
||||
<p>Some text about the coffee maker.</p>
|
||||
|
||||
<ha-embed-card provider="living-room">
|
||||
<script type="text/yaml">
|
||||
type: tile
|
||||
entity: switch.coffee_maker
|
||||
</script>
|
||||
</ha-embed-card></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><ha-embed-provider color-scheme="dark">…</ha-embed-provider></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) =>
|
||||
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) => {
|
||||
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><ha-embed-provider>
|
||||
<script type="text/yaml">
|
||||
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"
|
||||
</script>
|
||||
…
|
||||
</ha-embed-provider></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><script type="module" src="https://example.com/embed-demo-custom-card.js"></script>
|
||||
<ha-embed-card>
|
||||
<script type="text/yaml">
|
||||
type: custom:embed-demo-custom-card
|
||||
entity: light.office
|
||||
</script>
|
||||
</ha-embed-card></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 = `
|
||||
<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);</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><ha-embed-provider></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><ha-embed-card></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>
|
||||
@@ -0,0 +1 @@
|
||||
import "<%= latestEntryJS[0] %>";
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => ({}));
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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,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"],
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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> `;
|
||||
};
|
||||
|
||||
@@ -352,6 +352,7 @@ export class HaChipBase extends HaButton {
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(/&/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, "&"))}"`
|
||||
: "";
|
||||
};
|
||||
|
||||
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";
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
" [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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(" images/logo.png ", true)).toBe(
|
||||
"./images/logo.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the query string of a relative path", async () => {
|
||||
expect(await renderImageSrc("images/logo.png?v=1&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"b.png", true)).toBe(
|
||||
'./images/a"b.png'
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a relative path with entities left after decoding", async () => {
|
||||
expect(await renderImageSrc("&#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)",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,x",
|
||||
"HTTPS://example.com/a.png",
|
||||
"javascript: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}>`))
|
||||
|
||||
Reference in new issue
Block a user