Compare commits

..
Author SHA1 Message Date
Simon Lamon 608cdf168e Move live condition test inline 2026-06-05 20:53:18 +00:00
100 changed files with 2456 additions and 5026 deletions
+17 -7
View File
@@ -2,7 +2,7 @@
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
## Table of Contents
@@ -338,6 +338,11 @@ Common patterns:
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
**Gallery Documentation:**
- `gallery/src/pages/components/ha-dialog.markdown`
- `gallery/src/pages/components/ha-dialogs.markdown`
### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]`
@@ -356,6 +361,10 @@ Common patterns:
></ha-form>
```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
@@ -369,6 +378,10 @@ Common patterns:
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-alert.markdown`
### Keyboard Shortcuts (ShortcutManager)
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
@@ -392,6 +405,7 @@ The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming
- **Component definition**: `src/components/ha-tooltip.ts`
- **Usage example**: `src/components/ha-label.ts`
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
## Common Patterns
@@ -421,7 +435,7 @@ export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house.
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
```typescript
@customElement("hui-my-card")
@@ -494,10 +508,6 @@ this.hass.localize("ui.panel.config.updates.update_available", {
4. **Test**: `yarn test` - Add and run tests
5. **Build**: `script/build_frontend` - Test production build
### Gallery
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
### Common Pitfalls to Avoid
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
@@ -528,7 +538,7 @@ When creating a pull request, you **must** use the PR template located at `.gith
#### Terminology Standards
**Delete vs Remove**
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
- **Use "Remove"** for actions that can be restored or reapplied:
- Removing a user's permission
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -24
View File
@@ -33,9 +33,7 @@ const isWsl =
* compiler: import("@rspack/core").Compiler,
* contentBase: string,
* port: number,
* listenHost?: string,
* open?: boolean,
* logUrlAfterFirstBuild?: boolean,
* listenHost?: string
* }}
*/
const runDevServer = async ({
@@ -43,31 +41,16 @@ const runDevServer = async ({
contentBase,
port,
listenHost = undefined,
open = true,
logUrlAfterFirstBuild = false,
proxy = undefined,
}) => {
if (listenHost === undefined) {
// For dev container, we need to listen on all hosts
listenHost = env.isDevContainer() ? "0.0.0.0" : "localhost";
}
const url = `http://localhost:${port}`;
let loggedUrl = false;
if (logUrlAfterFirstBuild) {
compiler.hooks.done.tap("log-dev-server-url", () => {
if (loggedUrl) {
return;
}
loggedUrl = true;
setTimeout(() => {
log("[rspack-dev-server]", `Project is running at ${url}`);
}, 0);
});
}
const server = new RspackDevServer(
{
hot: false,
open,
open: true,
host: listenHost,
port,
static: {
@@ -87,9 +70,7 @@ const runDevServer = async ({
await server.start();
// Server listening
if (!logUrlAfterFirstBuild) {
log("[rspack-dev-server]", `Project is running at ${url}`);
}
log("[rspack-dev-server]", `Project is running at http://localhost:${port}`);
};
const doneHandler = (done) => (err, stats) => {
@@ -191,8 +172,6 @@ gulp.task("rspack-dev-server-gallery", () =>
contentBase: paths.gallery_output_root,
port: 8100,
listenHost: "0.0.0.0",
open: false,
logUrlAfterFirstBuild: true,
})
);
+1 -7
View File
@@ -1,7 +1,5 @@
// @ts-check
import { fileURLToPath } from "node:url";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import js from "@eslint/js";
@@ -13,10 +11,6 @@ import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
);
export default tseslint.config(
js.configs.recommended,
eslintConfigPrettier,
@@ -56,7 +50,7 @@ export default tseslint.config(
settings: {
"import-x/resolver": {
webpack: {
config: rspackConfigPath,
config: "./rspack.config.cjs",
},
},
},
-112
View File
@@ -1,112 +0,0 @@
# Gallery Agent Instructions
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
## Quick Reference
Run commands from the repository root unless noted otherwise:
```bash
gallery/script/develop_gallery # Start the gallery development server
gallery/script/build_gallery # Build the static gallery
yarn lint # ESLint, Prettier, TypeScript, and Lit checks
yarn lint:types # TypeScript compiler, without file arguments
```
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
## Purpose
The gallery is a developer and designer reference for Home Assistant frontend UI patterns. It documents component APIs, shows realistic Lovelace and more-info states, captures brand and copy guidance, and provides reproducible demos that are safe to inspect outside a running Home Assistant instance.
- Prefer demonstrating real production components from `src/` instead of creating gallery-only replacements.
- Keep fake state, sample data, and demo-only helpers inside `gallery/`.
- Do not move gallery stubs or demo data into production code unless a production feature explicitly needs them.
- Do not hand-edit generated output under `gallery/build/` or `gallery/dist/`.
## Structure
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
- `src/html/index.html.template`: HTML template used by the gallery build.
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
- `public/`: Static assets copied into the gallery output.
## Page Model
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
- A page may have only markdown, only a TypeScript demo, or both.
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
## Sidebar
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
- `category` must match the first directory name under `src/pages/`.
- `header` is the section label shown in the drawer.
- `pages` is optional. When present, listed pages keep that exact order.
- Pages in a category that are not listed are appended alphabetically after the listed pages.
- New categories without a sidebar entry are appended by the generator with their category name as the header.
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
## Markdown Pages
Use markdown pages for explanations, design guidance, API notes, and copy standards.
- Start with frontmatter when the page needs a title or subtitle.
- Use sentence case for titles, headings, labels, and UI copy.
- Put the live example before the reference API when that makes the page easier to scan.
- Use fenced code blocks with a language tag for copyable examples.
- Keep examples short and focused on the behavior being documented.
- Prefer real component names and attributes over prose-only descriptions.
- Use Home Assistant terminology from the root `AGENTS.md`.
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
## Demo Components
Use TypeScript demo pages for interactive or stateful examples.
- Import production components from `../../../src/...` or the correct relative path from the demo file.
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
The gallery ESLint config allows `console` for gallery diagnostics. Do not copy that exception into production frontend code.
## Content Standards
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
- Use `Home Assistant` in full, not `HA` or `HASS`.
- Use `integration` instead of `component` for product concepts.
- Use `Remove` for reversible disassociation and `Delete` for permanent deletion.
- Use `Add` for existing items and `Create` for something made from scratch.
- Avoid Latin abbreviations like `e.g.` and `i.e.` in prose.
- Avoid stitching sentence fragments together in production UI examples.
## Verification
- For markdown, sidebar, and page-generation changes, run `gallery/script/build_gallery`.
- For TypeScript demo or gallery shell changes, run the smallest relevant check plus `yarn lint` when practical.
- For type checking, run `yarn lint:types` without file arguments.
- For visual changes, run `gallery/script/develop_gallery` and check the affected page on desktop, narrow viewport, and RTL when relevant.
- If verification is skipped, state which command was skipped and why.
+18 -44
View File
@@ -1,50 +1,20 @@
import {
mdiAccountGroup,
mdiCalendarClock,
mdiDotsHorizontal,
mdiHome,
mdiInformationOutline,
mdiPalette,
mdiPuzzle,
mdiRobot,
mdiViewDashboard,
} from "@mdi/js";
export default [
{
// This section has no header and so all page links are shown directly in the sidebar
category: "concepts",
icon: mdiHome,
pages: ["home"],
},
{
category: "brand",
icon: mdiPalette,
header: "Brand",
},
{
category: "components",
icon: mdiPuzzle,
header: "Components",
},
{
category: "lovelace",
icon: mdiViewDashboard,
// Label for in the sidebar
header: "Dashboards",
// Specify order of pages. Any pages in the category folder but not listed here will
// automatically be added after the pages listed here.
pages: ["introduction"],
},
{
category: "more-info",
icon: mdiInformationOutline,
header: "More Info dialogs",
},
{
category: "automation",
icon: mdiRobot,
header: "Automation",
pages: [
"editor-trigger",
@@ -54,29 +24,33 @@ export default [
"trace-timeline",
],
},
{
category: "components",
header: "Components",
},
{
category: "more-info",
header: "More Info dialogs",
},
{
category: "misc",
header: "Miscellaneous",
},
{
category: "brand",
header: "Brand",
},
{
category: "user-test",
icon: mdiAccountGroup,
header: "Users",
pages: ["user-types", "configuration-menu"],
},
{
category: "date-time",
icon: mdiCalendarClock,
header: "Date and Time",
},
{
category: "misc",
icon: mdiDotsHorizontal,
header: "Miscellaneous",
pages: [
"entity-state",
"ha-markdown",
"integration-card",
"box-shadow",
"util-long-press",
"remove-delete-add-create",
"editing",
],
category: "design.home-assistant.io",
header: "About",
},
];
-121
View File
@@ -1,121 +0,0 @@
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
import { extractVars } from "../../../src/common/style/derived-css-vars";
import { animationStyles } from "../../../src/resources/theme/animations.globals";
import { coreStyles } from "../../../src/resources/theme/core.globals";
import { colorStyles } from "../../../src/resources/theme/color/color.globals";
import { coreColorStyles } from "../../../src/resources/theme/color/core.globals";
import { semanticColorStyles } from "../../../src/resources/theme/color/semantic.globals";
import { waColorStyles } from "../../../src/resources/theme/color/wa.globals";
import { mainStyles } from "../../../src/resources/theme/main.globals";
import { semanticStyles } from "../../../src/resources/theme/semantic.globals";
import { typographyStyles } from "../../../src/resources/theme/typography.globals";
import { waMainStyles } from "../../../src/resources/theme/wa.globals";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
export const GALLERY_THEME_STORAGE_KEY = "gallery-theme";
export const loadGalleryThemeSettings = (): ThemeSettings => {
const stored = localStorage.getItem(GALLERY_THEME_STORAGE_KEY);
if (!stored) {
return { theme: "default" };
}
try {
const parsed = JSON.parse(stored) as unknown;
const value =
parsed && typeof parsed === "object"
? (parsed as Partial<ThemeSettings>)
: {};
return {
theme: "default",
dark: typeof value.dark === "boolean" ? value.dark : undefined,
primaryColor:
typeof value.primaryColor === "string" ? value.primaryColor : undefined,
accentColor:
typeof value.accentColor === "string" ? value.accentColor : undefined,
};
} catch (_err) {
return { theme: "default" };
}
};
const LIGHT_THEME_STYLES = [
coreStyles,
mainStyles,
typographyStyles,
semanticStyles,
coreColorStyles,
semanticColorStyles,
colorStyles,
waColorStyles,
waMainStyles,
animationStyles,
];
const LIGHT_THEME_VARIABLES = LIGHT_THEME_STYLES.reduce<Record<string, string>>(
(variables, style) => {
for (const [key, value] of Object.entries(extractVars(style))) {
variables[`--${key}`] = value;
}
return variables;
},
{}
);
const LIGHT_THEME_VARIABLE_KEYS = Object.keys(LIGHT_THEME_VARIABLES);
const LIGHT_THEME_DEFAULTS_APPLIED = new WeakSet<HTMLElement>();
export const effectiveGalleryDarkMode = (
themeSettings: ThemeSettings,
systemDark: boolean
): boolean => themeSettings.dark ?? systemDark;
const galleryThemes = (darkMode: boolean): HomeAssistant["themes"] => ({
default_theme: "default",
default_dark_theme: null,
themes: {},
darkMode,
theme: "default",
});
const applyLightThemeDefaults = (element: HTMLElement, lightMode: boolean) => {
if (lightMode) {
for (const [key, value] of Object.entries(LIGHT_THEME_VARIABLES)) {
element.style.setProperty(key, value);
}
LIGHT_THEME_DEFAULTS_APPLIED.add(element);
return;
}
if (!LIGHT_THEME_DEFAULTS_APPLIED.has(element)) {
return;
}
for (const key of LIGHT_THEME_VARIABLE_KEYS) {
element.style.removeProperty(key);
}
LIGHT_THEME_DEFAULTS_APPLIED.delete(element);
};
export const applyFlippedGalleryTheme = (
element: HTMLElement,
themeSettings: ThemeSettings,
systemDark: boolean
) => {
const darkMode = !effectiveGalleryDarkMode(themeSettings, systemDark);
if (!darkMode) {
applyThemesOnElement(element, galleryThemes(false), undefined, {
dark: false,
});
applyLightThemeDefaults(element, true);
} else {
applyLightThemeDefaults(element, false);
}
applyThemesOnElement(element, galleryThemes(darkMode), "default", {
...themeSettings,
dark: darkMode,
});
element.style.colorScheme = darkMode ? "dark" : "light";
};
+63 -132
View File
@@ -1,83 +1,25 @@
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { html, LitElement, css, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/ha-card";
import "../../../src/components/ha-button";
import type { HaButton } from "../../../src/components/ha-button";
import type { ThemeSettings } from "../../../src/types";
import {
applyFlippedGalleryTheme,
effectiveGalleryDarkMode,
loadGalleryThemeSettings,
} from "../common/theme";
const mql = matchMedia("(prefers-color-scheme: dark)");
@customElement("demo-black-white-row")
class DemoBlackWhiteRow extends LitElement {
// eslint-disable-next-line lit/no-native-attributes
@property() title!: string;
@property({ attribute: false }) value?: unknown;
@property() value?: any;
@property({ type: Boolean }) public disabled = false;
@state() private _themeSettings = loadGalleryThemeSettings();
@state() private _systemDark = mql.matches;
@query(".flipped") private _flipped?: HTMLElement;
connectedCallback() {
super.connectedCallback();
mql.addEventListener("change", this._systemDarkChanged);
window.addEventListener(
"theme-settings-changed",
this._themeSettingsChanged as EventListener
);
}
disconnectedCallback() {
super.disconnectedCallback();
mql.removeEventListener("change", this._systemDarkChanged);
window.removeEventListener(
"theme-settings-changed",
this._themeSettingsChanged as EventListener
);
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._applyFlippedTheme();
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (
changedProperties.has("_themeSettings") ||
changedProperties.has("_systemDark")
) {
this._applyFlippedTheme();
}
}
protected render(): TemplateResult {
const currentLabel = effectiveGalleryDarkMode(
this._themeSettings,
this._systemDark
)
? "Dark mode"
: "Light mode";
const flippedLabel =
currentLabel === "Dark mode" ? "Light mode" : "Dark mode";
return html`
<div class="row">
<section class="content current" aria-label=${currentLabel}>
<h2>${currentLabel}</h2>
<div class="content light">
<ha-card .header=${this.title}>
<div class="card-content">
<slot name="light"></slot>
@@ -88,9 +30,8 @@ class DemoBlackWhiteRow extends LitElement {
</ha-button>
</div>
</ha-card>
</section>
<section class="content flipped" aria-label=${flippedLabel}>
<h2>${flippedLabel}</h2>
</div>
<div class="content dark">
<ha-card .header=${this.title}>
<div class="card-content">
<slot name="dark"></slot>
@@ -104,84 +45,65 @@ class DemoBlackWhiteRow extends LitElement {
${this.value
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
: nothing}
</section>
</div>
</div>
`;
}
handleSubmit(ev: Event) {
const content = (ev.target as HaButton).closest(".content");
if (!content) {
return;
}
fireEvent(this, "submitted" as any, {
slot: content.classList.contains("current") ? "light" : "dark",
});
}
private _themeSettingsChanged = (
ev: HASSDomEvent<Partial<ThemeSettings>>
) => {
this._themeSettings = {
...this._themeSettings,
...ev.detail,
theme: "default",
};
};
private _systemDarkChanged = (ev: MediaQueryListEvent) => {
this._systemDark = ev.matches;
};
private _applyFlippedTheme() {
if (!this._flipped) {
return;
}
applyFlippedGalleryTheme(
this._flipped,
this._themeSettings,
this._systemDark
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
handleSubmit(ev) {
const content = (ev.target as HaButton).closest(".content")!;
fireEvent(this, "submitted" as any, {
slot: content.classList.contains("light") ? "light" : "dark",
});
}
static styles = css`
:host {
display: block;
flex: 1;
min-block-size: 100%;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
inline-size: 100%;
min-block-size: 100%;
display: flex;
}
.content {
box-sizing: border-box;
min-inline-size: 0;
padding: var(--ha-space-8);
padding: 50px 0;
background-color: var(--primary-background-color);
color: var(--primary-text-color);
}
.light {
flex: 1;
padding-left: 50px;
padding-right: 50px;
box-sizing: border-box;
}
.light ha-card {
margin-left: auto;
}
.dark {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
flex: 1;
padding-left: 50px;
box-sizing: border-box;
flex-wrap: wrap;
}
ha-card {
width: 100%;
}
h2 {
margin: 0;
color: var(--primary-text-color);
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
width: 400px;
}
pre {
box-sizing: border-box;
width: 100%;
margin: 0;
width: 300px;
margin: 0 16px 0;
overflow: auto;
color: var(--primary-text-color);
}
@@ -190,18 +112,27 @@ class DemoBlackWhiteRow extends LitElement {
flex-direction: row-reverse;
border-top: none;
}
@media only screen and (max-width: 1000px) {
.row {
grid-template-columns: 1fr;
@media only screen and (max-width: 1500px) {
.light {
flex: initial;
}
.content {
}
@media only screen and (max-width: 1000px) {
.light,
.dark {
padding: 16px;
}
.row,
.dark {
flex-direction: column;
}
ha-card {
margin: 0 auto;
width: 100%;
max-width: 400px;
}
pre {
margin: 0;
margin: 16px auto;
}
}
`;
+13 -1
View File
@@ -1,5 +1,6 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
@@ -15,12 +16,17 @@ class DemoCards extends LitElement {
@state() private _showConfig = false;
@query("#container") private _container!: HTMLElement;
render() {
return html`
<ha-demo-options>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
<ha-formfield label="Dark theme">
<ha-switch @change=${this._darkThemeToggled}> </ha-switch>
</ha-formfield>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -42,6 +48,12 @@ class DemoCards extends LitElement {
this._showConfig = ev.target.checked;
}
private _darkThemeToggled(ev) {
applyThemesOnElement(this._container, { themes: {} } as any, "default", {
dark: ev.target.checked,
});
}
static styles = css`
.cards {
display: flex;
+23 -2
View File
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
@@ -20,6 +21,9 @@ class DemoMoreInfos extends LitElement {
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
<ha-formfield label="Dark theme">
<ha-switch @change=${this._darkThemeToggled}> </ha-switch>
</ha-formfield>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -47,16 +51,33 @@ class DemoMoreInfos extends LitElement {
justify-content: center;
}
demo-more-info {
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
margin: 16px 16px 32px;
}
ha-formfield {
margin-right: var(--ha-space-4);
margin-right: 16px;
}
`;
private _showConfigToggled(ev) {
this._showConfig = ev.target.checked;
}
private _darkThemeToggled(ev) {
applyThemesOnElement(
this.shadowRoot!.querySelector("#container"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: false,
theme: "default",
},
"default",
{
dark: ev.target.checked,
}
);
}
}
declare global {
@@ -1,153 +0,0 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type { ThemeSettings } from "../../../src/types";
import {
applyFlippedGalleryTheme,
effectiveGalleryDarkMode,
loadGalleryThemeSettings,
} from "../common/theme";
const mql = matchMedia("(prefers-color-scheme: dark)");
export const THEME_COMPARISON_PANELS = [
{ slot: "current" },
{ slot: "flipped" },
] as const;
@customElement("demo-theme-comparison")
export class DemoThemeComparison extends LitElement {
@state() private _themeSettings = loadGalleryThemeSettings();
@state() private _systemDark = mql.matches;
@query(".flipped") private _flipped?: HTMLElement;
connectedCallback() {
super.connectedCallback();
mql.addEventListener("change", this._systemDarkChanged);
window.addEventListener(
"theme-settings-changed",
this._themeSettingsChanged as EventListener
);
}
disconnectedCallback() {
super.disconnectedCallback();
mql.removeEventListener("change", this._systemDarkChanged);
window.removeEventListener(
"theme-settings-changed",
this._themeSettingsChanged as EventListener
);
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._applyFlippedTheme();
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (
changedProperties.has("_themeSettings") ||
changedProperties.has("_systemDark")
) {
this._applyFlippedTheme();
}
}
protected render(): TemplateResult {
const currentLabel = effectiveGalleryDarkMode(
this._themeSettings,
this._systemDark
)
? "Dark mode"
: "Light mode";
const flippedLabel =
currentLabel === "Dark mode" ? "Light mode" : "Dark mode";
return html`
<section class="panel" aria-label=${currentLabel}>
<h2>${currentLabel}</h2>
<slot name="current"></slot>
</section>
<section class="panel flipped" aria-label=${flippedLabel}>
<h2>${flippedLabel}</h2>
<slot name="flipped"></slot>
</section>
`;
}
private _themeSettingsChanged = (
ev: HASSDomEvent<Partial<ThemeSettings>>
) => {
this._themeSettings = {
...this._themeSettings,
...ev.detail,
theme: "default",
};
};
private _systemDarkChanged = (ev: MediaQueryListEvent) => {
this._systemDark = ev.matches;
};
private _applyFlippedTheme() {
if (!this._flipped) {
return;
}
applyFlippedGalleryTheme(
this._flipped,
this._themeSettings,
this._systemDark
);
}
static styles = css`
:host {
box-sizing: border-box;
display: grid;
flex: 1;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
inline-size: 100%;
min-block-size: 100%;
}
.panel {
box-sizing: border-box;
min-block-size: 100%;
min-inline-size: 0;
padding: var(--ha-space-6);
background-color: var(--primary-background-color);
color: var(--primary-text-color);
}
h2 {
margin: 0 0 var(--ha-space-4);
color: var(--primary-text-color);
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
}
::slotted(*) {
box-sizing: border-box;
inline-size: 100%;
}
@media only screen and (max-width: 1000px) {
:host {
grid-template-columns: 1fr;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-theme-comparison": DemoThemeComparison;
}
}
@@ -1,87 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/ha-card";
import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public rtl = false;
protected render() {
return html`
<div class="content">
<ha-card .header=${"Appearance"}>
<div class="card-content">
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
.description=${"Choose the mode and colors used throughout the gallery."}
.labels=${{
mode: "Theme mode",
autoMode: "Auto",
lightMode: "Light",
darkMode: "Dark",
primaryColor: "Primary color",
accentColor: "Accent color",
reset: "Reset",
}}
.showThemePicker=${false}
></ha-theme-settings>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">Right-to-left layout</span>
<span slot="description">
Preview the gallery with right-to-left text direction.
</span>
<ha-switch
.checked=${this.rtl}
@change=${this._rtlChanged}
></ha-switch>
</ha-settings-row>
</ha-card>
</div>
`;
}
private _rtlChanged(ev: Event) {
fireEvent(this, "gallery-rtl-changed", {
rtl: (ev.currentTarget as HaSwitch).checked,
});
}
static styles = css`
.content {
max-width: 800px;
margin: 0 auto;
padding: var(--ha-space-4);
}
ha-card {
overflow: hidden;
}
`;
}
declare global {
interface HASSDomEvents {
"gallery-rtl-changed": { rtl: boolean };
}
interface HTMLElementTagNameMap {
"gallery-settings": GallerySettings;
}
}
+14 -4
View File
@@ -13,10 +13,13 @@ class PageDescription extends HaMarkdown {
return nothing;
}
const subtitle = PAGES[this.page].metadata.subtitle;
return html`
${subtitle ? html`<div class="subtitle">${subtitle}</div>` : nothing}
<div class="heading">
<div class="title">
${PAGES[this.page].metadata.title || this.page.split("/")[1]}
</div>
<div class="subtitle">${PAGES[this.page].metadata.subtitle}</div>
</div>
${until(
PAGES[this.page]
.description()
@@ -29,9 +32,16 @@ class PageDescription extends HaMarkdown {
static styles = [
HaMarkdown.styles,
css`
.subtitle {
.heading {
padding: 16px;
border-bottom: 1px solid var(--secondary-background-color);
}
.title {
font-size: 42px;
line-height: var(--ha-line-height-condensed);
padding-bottom: 8px;
}
.subtitle {
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-normal);
}
+15 -2
View File
@@ -16,9 +16,22 @@ class HaDemoOptions extends LitElement {
css`
:host {
display: block;
background-color: var(--primary-background-color);
background-color: var(--light-primary-color);
margin-left: 60px
margin-right: 60px;
display: var(--layout-horizontal_-_display);
-ms-flex-direction: var(--layout-horizontal_-_-ms-flex-direction);
-webkit-flex-direction: var(
--layout-horizontal_-_-webkit-flex-direction
);
flex-direction: var(--layout-horizontal_-_flex-direction);
-ms-flex-align: var(--layout-center_-_-ms-flex-align);
-webkit-align-items: var(--layout-center_-_-webkit-align-items);
align-items: var(--layout-center_-_align-items);
position: relative;
padding: var(--ha-space-2) var(--ha-space-16) var(--ha-space-1);
height: 64px;
padding: 0 16px;
pointer-events: none;
font-size: var(--ha-font-size-xl);
}
`,
+156 -533
View File
@@ -1,183 +1,161 @@
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
import type { HaDrawer } from "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-sidebar";
import "../../src/components/item/ha-list-item-button";
import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import type { HomeAssistant, ThemeSettings } from "../../src/types";
import { PAGES, SIDEBAR } from "../build/import-pages";
import {
GALLERY_THEME_STORAGE_KEY,
loadGalleryThemeSettings,
} from "./common/theme";
import "./components/gallery-settings";
import "./components/page-description";
const RTL_STORAGE_KEY = "gallery-rtl";
const SETTINGS_PAGE = "settings";
const GITHUB_DEMO_URL =
"https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/";
interface GalleryPage {
metadata: Record<string, unknown>;
description?: unknown;
demo?: unknown;
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages: string[];
}
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
const galleryLocalize = (key: string) =>
(
({
"ui.sidebar.sidebar_toggle": "Toggle sidebar",
"ui.notification_drawer.title": "Notifications",
"ui.sidebar.external_app_configuration": "App configuration",
"panel.config": "Settings",
}) as Record<string, string>
)[key] ?? key;
const galleryConnection = {
subscribeMessage(
callback: (message: unknown) => void,
message: { type?: string }
) {
if (message.type === "frontend/subscribe_user_data") {
callback({ value: { panelOrder: [], hiddenPanels: [] } });
} else if (message.type === "persistent_notification/subscribe") {
callback({ type: "current", notifications: {} });
}
return Promise.resolve(() => undefined);
const FAKE_HASS = {
// Just enough for computeRTL for notification-manager
language: "en",
translationMetadata: {
translations: {},
},
sendMessagePromise() {
return Promise.resolve({ value: null });
},
} as unknown as Connection;
};
@customElement("ha-gallery")
class HaGallery extends LitElement {
@state() private _page = this._pageFromLocation();
@state() private _page =
document.location.hash.substring(1) ||
`${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
@state() private _rtl = localStorage.getItem(RTL_STORAGE_KEY) === "true";
@state() private _themeSettings = loadGalleryThemeSettings();
@state() private _systemDark = mql.matches;
@query("notification-manager")
private _notifications!: HTMLElementTagNameMap["notification-manager"];
@query("ha-sidebar")
private _sidebar?: HTMLElementTagNameMap["ha-sidebar"];
@query(".gallery-nav-item[selected]")
private _selectedNavigationItem?: HTMLElementTagNameMap["ha-list-item-button"];
@query("ha-drawer")
private _drawer!: HaDrawer;
private _narrow = window.matchMedia("(max-width: 600px)").matches;
@state() private _drawerOpen = !this._narrow;
render() {
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
const sidebar: unknown[] = [];
for (const group of SIDEBAR) {
const links: unknown[] = [];
for (const page of group.pages!) {
const key = `${group.category}/${page}`;
const active = this._page === key;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
const title = PAGES[key].metadata.title || page;
links.push(html`
<a ?active=${active} href=${`#${group.category}/${page}`}>${title}</a>
`);
}
sidebar.push(
group.header
? html`
<ha-expansion-panel .header=${group.header}>
${links}
</ha-expansion-panel>
`
: links
);
}
return html`
<ha-drawer
.direction=${this._rtl ? "rtl" : "ltr"}
.open=${this._drawerOpen}
.open=${!this._narrow}
.type=${this._narrow ? "modal" : "dismissible"}
>
<ha-sidebar
.hass=${this._galleryHass}
.narrow=${this._narrow}
.route=${{ prefix: "", path: this._page }}
.alwaysExpand=${true}
sidebar-title="Home Assistant Design"
@hass-toggle-menu=${this._toggleDrawer}
>
${this._renderSidebarNavigation()} ${this._renderSettingsItem()}
</ha-sidebar>
<div class="drawer-title">Home Assistant Design</div>
<div class="sidebar">${sidebar}</div>
<div slot="appContent" class="app-content">
<ha-top-app-bar-fixed .narrow=${this._narrow}>
${this._narrow || !this._drawerOpen
? html`<ha-icon-button
slot="navigationIcon"
@click=${this._toggleDrawer}
.path=${mdiMenu}
></ha-icon-button>`
: nothing}
<ha-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@click=${this._menuTapped}
.path=${mdiMenu}
></ha-icon-button>
<div slot="title">
${isSettingsPage
? "Settings"
: page?.metadata.title || this._page.split("/")[1]}
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
</div>
<div class="content">
${isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@theme-settings-changed=${this._themeSettingsChanged}
@gallery-rtl-changed=${this._rtlChanged}
></gallery-settings>`
: html`
${page?.description
? html`
<page-description .page=${this._page}>
</page-description>
`
: nothing}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
`}
</div>
${isSettingsPage || !page ? nothing : this._renderPageFooter(page)}
</ha-top-app-bar-fixed>
<div class="content">
${PAGES[this._page].description
? html`
<page-description .page=${this._page}></page-description>
`
: ""}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
</div>
<div class="page-footer">
<div class="edit-docs">
<div class="header">Help us to improve our documentation</div>
<div class="secondary">
Suggest an edit to this page, or provide/view feedback for this
page.
</div>
<div>
${PAGES[this._page].description ||
Object.keys(PAGES[this._page].metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: ""}
${PAGES[this._page].demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: ""}
</div>
</div>
<div class="rtl-toggle">
<ha-icon-button
@click=${this._toggleRtl}
.label=${this._rtl ? "Switch to LTR" : "Switch to RTL"}
>
<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>
</ha-icon-button>
</div>
</div>
</div>
</ha-drawer>
<notification-manager
.hass=${this._galleryHass}
.hass=${FAKE_HASS}
id="notifications"
></notification-manager>
`;
}
connectedCallback() {
super.connectedCallback();
mql.addEventListener("change", this._systemDarkChanged);
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._applyDirection();
this._applyTheme();
this.addEventListener("show-notification", (ev) =>
this._notifications.showDialog({ message: ev.detail.message })
@@ -193,26 +171,16 @@ class HaGallery extends LitElement {
}
});
if (document.location.hash.substring(1) !== this._page) {
document.location.hash = this._page;
}
document.location.hash = this._page;
window.addEventListener("hashchange", this._hashChanged);
window.addEventListener("hashchange", () => {
this._page = document.location.hash.substring(1);
if (this._narrow) {
this._drawer.open = false;
}
});
}
disconnectedCallback() {
super.disconnectedCallback();
mql.removeEventListener("change", this._systemDarkChanged);
window.removeEventListener("hashchange", this._hashChanged);
}
private _hashChanged = () => {
this._page = this._pageFromLocation();
if (this._narrow) {
this._drawerOpen = false;
}
};
updated(changedProps: PropertyValues) {
super.updated(changedProps);
@@ -220,335 +188,37 @@ class HaGallery extends LitElement {
this._applyDirection();
}
if (changedProps.has("_themeSettings") || changedProps.has("_systemDark")) {
this._applyTheme();
}
if (!changedProps.has("_page")) {
return;
}
if (this._page === SETTINGS_PAGE) {
return;
}
if (PAGES[this._page].demo) {
PAGES[this._page].demo();
}
void this._scrollSelectedNavigationItemIntoView();
}
const menuItem = this.shadowRoot!.querySelector(
`a[href="#${this._page}"]`
)!;
private async _scrollSelectedNavigationItemIntoView() {
const menuItem = this._selectedNavigationItem;
if (!menuItem) {
return;
}
// Make sure section is expanded before measuring the selected item.
// Make sure section is expanded
if (menuItem.parentElement instanceof HaExpansionPanel) {
menuItem.parentElement.expanded = true;
await menuItem.parentElement.updateComplete;
}
const scrollable = this._sidebar?.shadowRoot?.querySelector<HTMLElement>(
"ha-list-nav.before-spacer"
);
if (!scrollable) {
return;
}
requestAnimationFrame(() => {
const itemRect = menuItem.getBoundingClientRect();
const scrollableRect = scrollable.getBoundingClientRect();
const targetScrollTop =
scrollable.scrollTop +
itemRect.top -
scrollableRect.top -
(scrollableRect.height - itemRect.height) / 2;
scrollable.scrollTo({
top: Math.min(
Math.max(0, targetScrollTop),
scrollable.scrollHeight - scrollable.clientHeight
),
left: 0,
});
scrollable.scrollLeft = 0;
});
}
private _renderSidebarNavigation() {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const links: unknown[] = [];
const expanded = group.pages.some(
(page) => this._page === `${group.category}/${page}`
);
for (const page of group.pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
)
);
}
sidebar.push(
group.header
? html`
<ha-expansion-panel
slot="main-navigation"
class="gallery-sidebar-section"
.header=${group.header}
?expanded=${expanded}
>
${group.icon
? html`<ha-svg-icon
slot="leading-icon"
class="gallery-sidebar-icon"
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${links}
</ha-expansion-panel>
`
: links
);
}
return sidebar;
private _menuTapped() {
this._drawer.open = !this._drawer.open;
}
private _renderPageLink(
page: string,
title: string,
slot?: string,
iconPath?: string
) {
return html`
<ha-list-item-button
slot=${ifDefined(slot)}
class=${classMap({
"gallery-nav-item": true,
"has-icon": Boolean(iconPath),
selected: this._page === page,
})}
?selected=${this._page === page}
href=${`#${page}`}
>
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: nothing}
<span slot="headline">${title}</span>
</ha-list-item-button>
`;
}
private _renderSettingsItem() {
return html`
<ha-list-item-button
slot="fixed-navigation"
class=${classMap({
"gallery-settings-item": true,
selected: this._page === SETTINGS_PAGE,
})}
?selected=${this._page === SETTINGS_PAGE}
href="#settings"
>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline">Settings</span>
</ha-list-item-button>
`;
}
private _renderPageFooter(page: GalleryPage) {
return html`<div class="page-footer">
<div class="edit-docs">
<div class="header">Help us to improve our documentation</div>
<div class="secondary">
Suggest an edit to this page, or provide/view feedback for this page.
</div>
<div>
${page.description || Object.keys(page.metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: nothing}
${page.demo
? html`
<a href=${`${GITHUB_DEMO_URL}${this._page}.ts`} target="_blank">
Edit demo
</a>
`
: nothing}
</div>
</div>
</div>`;
}
private _toggleDrawer(ev?: Event) {
ev?.stopPropagation();
this._drawerOpen = !this._drawerOpen;
private _toggleRtl() {
this._rtl = !this._rtl;
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
}
private _applyDirection() {
setDirectionStyles(this._rtl ? "rtl" : "ltr", this);
}
private _themeSettingsChanged(ev: HASSDomEvent<Partial<ThemeSettings>>) {
this._themeSettings = {
...this._themeSettings,
...ev.detail,
theme: "default",
};
localStorage.setItem(
GALLERY_THEME_STORAGE_KEY,
JSON.stringify(this._themeSettings)
);
}
private _rtlChanged(ev: HASSDomEvent<{ rtl: boolean }>) {
this._rtl = ev.detail.rtl;
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
}
private _systemDarkChanged = (ev: MediaQueryListEvent) => {
this._systemDark = ev.matches;
};
private _applyTheme() {
applyThemesOnElement(
document.documentElement,
this._themes,
"default",
this._themeSettings,
true
);
let schemeMeta = document.querySelector("meta[name=color-scheme]");
if (!schemeMeta) {
schemeMeta = document.createElement("meta");
schemeMeta.setAttribute("name", "color-scheme");
document.head.appendChild(schemeMeta);
}
schemeMeta.setAttribute(
"content",
this._effectiveDarkMode ? "dark" : "light"
);
document.documentElement.style.colorScheme = this._effectiveDarkMode
? "dark"
: "light";
const themeMeta = document.querySelector("meta[name=theme-color]");
if (themeMeta) {
if (!themeMeta.hasAttribute("default-content")) {
themeMeta.setAttribute(
"default-content",
themeMeta.getAttribute("content") ?? ""
);
}
const styles = getComputedStyle(document.documentElement);
const themeColor =
styles.getPropertyValue("--app-theme-color").trim() ||
styles.getPropertyValue("--primary-background-color").trim() ||
themeMeta.getAttribute("default-content") ||
"";
themeMeta.setAttribute("content", themeColor);
}
}
private _pageFromLocation() {
const page = document.location.hash.substring(1);
return page === SETTINGS_PAGE || page in PAGES ? page : DEFAULT_PAGE;
}
private get _effectiveDarkMode() {
return this._themeSettings.dark ?? this._systemDark;
}
private get _themes(): HomeAssistant["themes"] {
return {
default_theme: "default",
default_dark_theme: null,
themes: {},
darkMode: this._effectiveDarkMode,
theme: "default",
};
}
private get _galleryHass(): HomeAssistant {
return {
auth: {},
areas: {},
config: {},
connected: true,
connection: galleryConnection,
debugConnection: false,
devices: {},
dockedSidebar: "docked",
enableShortcuts: true,
entities: {},
floors: {},
hassUrl: (path) => path,
kioskMode: false,
language: "en",
loadBackendTranslation: async () => galleryLocalize,
loadFragmentTranslation: async () => undefined,
locale: {
language: "en",
number_format: "language",
time_format: "language",
date_format: "language",
first_weekday: "language",
time_zone: "local",
},
localize: galleryLocalize,
panelUrl: this._page,
panels: {},
selectedLanguage: null,
selectedTheme: this._themeSettings,
services: {},
states: {},
suspendWhenHidden: false,
systemData: {},
themes: this._themes,
translationMetadata: { fragments: [], translations: {} },
user: {
id: "gallery",
is_admin: false,
is_owner: false,
name: "Settings",
credentials: [],
mfa_modules: [],
},
userData: {},
vibrate: false,
callApi: async () => undefined,
callApiRaw: async () => new Response(),
callService: async () => ({ context: { id: "gallery" } }),
callWS: async () => undefined,
fetchWithAuth: async () => new Response(),
sendWS: () => undefined,
} as unknown as HomeAssistant;
}
static styles = [
haStyle,
css`
@@ -556,103 +226,49 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 300px;
--ha-sidebar-expanded-width: 300px;
--ha-sidebar-expanded-item-width: 292px;
--ha-sidebar-expanded-section-item-width: 256px;
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
--app-header-border-bottom: 1px solid var(--divider-color);
--ha-sidebar-width: 256px;
--header-height: 64px;
}
.gallery-sidebar-section {
color: var(--sidebar-text-color);
.sidebar {
box-sizing: border-box;
margin: 0 var(--ha-space-1) var(--ha-space-1);
overflow-x: hidden;
border-radius: var(--ha-border-radius-sm);
--expansion-panel-summary-padding: 0 var(--ha-space-2);
max-height: calc(100vh - var(--header-height));
overflow-y: auto;
padding: 4px;
}
.gallery-sidebar-section::part(summary) {
min-height: var(--ha-space-10);
border-radius: var(--ha-border-radius-sm);
.drawer-title {
align-items: center;
box-sizing: border-box;
color: var(--primary-text-color);
display: flex;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
min-height: var(--header-height);
padding: 0 16px;
}
.gallery-sidebar-section .gallery-nav-item {
margin-inline-start: var(--ha-space-4);
width: var(--ha-sidebar-expanded-section-item-width, 248px);
}
.gallery-sidebar-icon,
.gallery-nav-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
flex-shrink: 0;
height: var(--ha-space-6);
width: var(--ha-space-6);
}
.gallery-sidebar-icon {
margin-inline-end: var(--ha-space-3);
}
.gallery-nav-item,
.gallery-settings-item {
flex-shrink: 0;
margin: 0 var(--ha-space-1) var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 0;
--ha-row-item-padding-inline: var(--ha-space-3);
.sidebar a {
color: var(--primary-text-color);
display: block;
padding: 12px;
text-decoration: none;
position: relative;
width: var(--ha-sidebar-expanded-item-width, 248px);
color: var(--sidebar-text-color);
}
.gallery-nav-item.has-icon,
.gallery-settings-item {
--ha-row-item-gap: var(--ha-space-3);
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
}
.gallery-nav-item::part(headline),
.gallery-settings-item::part(headline) {
color: inherit;
}
.gallery-nav-item[selected],
.gallery-settings-item[selected] {
color: var(--sidebar-selected-icon-color);
}
.gallery-nav-item[selected]::before,
.gallery-settings-item[selected]::before {
border-radius: var(--ha-border-radius-sm);
.sidebar a[active]::before {
border-radius: var(--ha-border-radius-lg);
position: absolute;
top: 0;
right: 0;
right: 2px;
bottom: 0;
left: 0;
left: 2px;
pointer-events: none;
content: "";
transition: opacity 15ms linear;
will-change: opacity;
background-color: var(--sidebar-selected-icon-color);
opacity: var(--dark-divider-opacity);
}
.gallery-settings-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
flex-shrink: 0;
height: var(--ha-space-6);
width: var(--ha-space-6);
}
.gallery-settings-item[selected] ha-svg-icon[slot="start"] {
color: var(--sidebar-selected-icon-color);
}
.gallery-nav-item[selected] ha-svg-icon[slot="start"] {
color: var(--sidebar-selected-icon-color);
opacity: 0.12;
}
.app-content {
@@ -667,16 +283,11 @@ class HaGallery extends LitElement {
}
.content {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1;
padding-top: var(--ha-space-4);
}
page-description {
display: block;
margin: 0 var(--ha-space-4) var(--ha-space-4);
margin: 16px;
}
.page-footer {
@@ -713,6 +324,18 @@ class HaGallery extends LitElement {
margin: 0 8px;
text-decoration: none;
}
.rtl-toggle {
padding: var(--ha-space-4);
display: inline-flex;
align-items: flex-end;
margin-top: 12px !important;
}
.rtl-toggle ha-icon-button {
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-pill);
}
`,
];
}
+35 -11
View File
@@ -1,11 +1,11 @@
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-alert";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const alerts: {
title?: string;
@@ -135,10 +135,10 @@ const alerts: {
export class DemoHaAlert extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-alert ${mode} demo">
<div class="card-content">
${alerts.map(
(alert) => html`
@@ -154,19 +154,43 @@ export class DemoHaAlert extends LitElement {
)}
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
ha-alert {
display: block;
+34 -12
View File
@@ -1,12 +1,12 @@
import { mdiButtonCursor, mdiHome } from "@mdi/js";
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-badge";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const badges: {
type?: "badge" | "button";
@@ -60,10 +60,10 @@ const badges: {
export class DemoHaBadge extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-badge ${mode} demo">
<div class="card-content">
${badges.map(
(badge) => html`
@@ -78,23 +78,45 @@ export class DemoHaBadge extends LitElement {
)}
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-6);
}
`;
+34 -12
View File
@@ -1,13 +1,13 @@
import { mdiHome } from "@mdi/js";
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import { titleCase } from "../../../../src/common/string/title-case";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const appearances = ["accent", "filled", "plain"];
const variants = ["brand", "danger", "neutral", "warning", "success"];
@@ -16,10 +16,10 @@ const variants = ["brand", "danger", "neutral", "warning", "success"];
export class DemoHaButton extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<div class="card-content">
${variants.map(
(variant) => html`
@@ -112,22 +112,45 @@ export class DemoHaButton extends LitElement {
)}
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
@@ -136,7 +159,6 @@ export class DemoHaButton extends LitElement {
}
.card-content div {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
`;
+2 -2
View File
@@ -26,7 +26,7 @@ const chips: {
export class DemoHaChips extends LitElement {
protected render(): TemplateResult {
return html`
<ha-card>
<ha-card header="ha-chip demo">
<div class="card-content">
<p>Action chip</p>
<ha-chip-set>
@@ -82,7 +82,7 @@ export class DemoHaChips extends LitElement {
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
: ""}
${chip.content}
</ha-input-chip>
`
@@ -9,11 +9,9 @@ import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-switch";
import type { HaControlSwitch } from "../../../../src/components/ha-control-switch";
import type { HASSDomTargetEvent } from "../../../../src/common/dom/fire_event";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const switches: {
id: string;
@@ -47,72 +45,106 @@ const switches: {
export class DemoHaControlSwitch extends LitElement {
@state() private checked = false;
handleValueChanged(e: HASSDomTargetEvent<HaControlSwitch>) {
this.checked = e.target.checked;
handleValueChanged(e: any) {
this.checked = e.target.checked as boolean;
}
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<div class="card-content">
<label id="${slot}-${id}">${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-switch
.checked=${this.checked}
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.pathOn=${mdiLightbulb}
.pathOff=${mdiLightbulbOff}
.label=${label}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
</div>
`;
})}
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-switches">
${repeat(switches, (sw) => {
const { label, ...config } = sw;
return html`
<div class="themes">
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-control-switch ${mode}">
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<div class="card-content">
<label id="${mode}-${id}">${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-switch
.checked=${this.checked}
vertical
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.pathOn=${mdiLightbulb}
.pathOff=${mdiLightbulbOff}
.label=${label}
.pathOn=${mdiGarageOpen}
.pathOff=${mdiGarage}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
`;
})}
</div>
`;
})}
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-switches">
${repeat(switches, (sw) => {
const { label, ...config } = sw;
return html`
<ha-control-switch
.checked=${this.checked}
vertical
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.label=${label}
.pathOn=${mdiGarageOpen}
.pathOff=${mdiGarage}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
`;
})}
</div>
</div>
</div>
</ha-card>
</ha-card>
</div>
`
)}
</demo-theme-comparison>
</div>
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: block;
}
.themes {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
margin: 0;
width: 100%;
max-width: 600px;
margin: 0 auto;
}
pre {
margin-top: 0;
+34 -11
View File
@@ -8,25 +8,25 @@ import {
mdiContentPaste,
mdiDelete,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-dropdown-item";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-svg-icon";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
@customElement("demo-components-ha-dropdown")
export class DemoHaDropdown extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<div class="card-content">
<ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
@@ -74,22 +74,45 @@ export class DemoHaDropdown extends LitElement {
</ha-dropdown>
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
+1 -1
View File
@@ -12,7 +12,7 @@ const SMALL_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
export class DemoHaFaded extends LitElement {
protected render(): TemplateResult {
return html`
<ha-card>
<ha-card header="ha-faded demo">
<div class="card-content">
<h3>Long text directly as slotted content</h3>
<ha-faded>${LONG_TEXT}</ha-faded>
+166 -180
View File
@@ -1,8 +1,9 @@
import { ContextProvider } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/input/ha-input";
@@ -10,15 +11,6 @@ import "../../../../src/components/input/ha-input-copy";
import "../../../../src/components/input/ha-input-multi";
import "../../../../src/components/input/ha-input-search";
import { internationalizationContext } from "../../../../src/data/context";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../../../src/data/translation";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copy": "Copy",
@@ -30,25 +22,6 @@ const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copied_clipboard": "Copied to clipboard",
};
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
language: "en",
selectedLanguage: null,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
translationMetadata: { fragments: [], translations: {} },
loadBackendTranslation: async () => localize,
loadFragmentTranslation: async () => localize,
};
@customElement("demo-components-ha-input")
export class DemoHaInput extends LitElement {
constructor() {
@@ -56,171 +29,185 @@ export class DemoHaInput extends LitElement {
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
new ContextProvider(this, {
context: internationalizationContext,
initialValue: DEMO_I18N,
initialValue: {
localize: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
},
});
}
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<div slot=${slot} class="panel-content">
<ha-card>
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-input label="Default"></ha-input>
<ha-input label="With value" value="Hello"></ha-input>
<ha-input
label="With placeholder"
placeholder="Type here..."
></ha-input>
</div>
<h3>Input types</h3>
<div class="row">
<ha-input label="Text" type="text" value="Text"></ha-input>
<ha-input
label="Number"
type="number"
value="42"
></ha-input>
<ha-input
label="Email"
type="email"
placeholder="[email protected]"
></ha-input>
</div>
<div class="row">
<ha-input
label="Password"
type="password"
value="secret"
password-toggle
></ha-input>
<ha-input label="URL" type="url" placeholder="https://...">
</ha-input>
<ha-input label="Date" type="date"></ha-input>
</div>
<h3>States</h3>
<div class="row">
<ha-input
label="Disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
label="Readonly"
readonly
value="Readonly"
></ha-input>
<ha-input label="Required" required></ha-input>
</div>
<div class="row">
<ha-input
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-input>
<ha-input
label="With hint"
hint="This is a hint"
></ha-input>
<ha-input
label="With clear"
with-clear
value="Clear me"
></ha-input>
</div>
<h3>With slots</h3>
<div class="row">
<ha-input label="With prefix">
<span slot="start">$</span>
</ha-input>
<ha-input label="With suffix">
<span slot="end">kg</span>
</ha-input>
<ha-input label="With icon">
<ha-svg-icon
.path=${mdiMagnify}
slot="start"
></ha-svg-icon>
</ha-input>
</div>
<h3>Appearance: outlined</h3>
<div class="row">
<ha-input
appearance="outlined"
label="Outlined"
value="Hello"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined invalid"
invalid
validation-message="Required"
></ha-input>
</div>
<div class="row">
<ha-input
appearance="outlined"
placeholder="Placeholder only"
></ha-input>
</div>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-input in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-input label="Default"></ha-input>
<ha-input label="With value" value="Hello"></ha-input>
<ha-input
label="With placeholder"
placeholder="Type here..."
></ha-input>
</div>
</ha-card>
<ha-card header="Derivatives">
<div class="card-content">
<h3>ha-input-search</h3>
<ha-input-search label="Search label"></ha-input-search>
<ha-input-search appearance="outlined"></ha-input-search>
<h3>ha-input-copy</h3>
<ha-input-copy
value="my-api-token-123"
masked-value="••••••••••••••••••"
masked-toggle
></ha-input-copy>
<h3>ha-input-multi</h3>
<ha-input-multi
label="URL"
add-label="Add URL"
.value=${["https://example.com"]}
></ha-input-multi>
<h3>Input types</h3>
<div class="row">
<ha-input label="Text" type="text" value="Text"></ha-input>
<ha-input label="Number" type="number" value="42"></ha-input>
<ha-input
label="Email"
type="email"
placeholder="[email protected]"
></ha-input>
</div>
</ha-card>
</div>
`
)}
</demo-theme-comparison>
<div class="row">
<ha-input
label="Password"
type="password"
value="secret"
password-toggle
></ha-input>
<ha-input label="URL" type="url" placeholder="https://...">
</ha-input>
<ha-input label="Date" type="date"></ha-input>
</div>
<h3>States</h3>
<div class="row">
<ha-input
label="Disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
label="Readonly"
readonly
value="Readonly"
></ha-input>
<ha-input label="Required" required></ha-input>
</div>
<div class="row">
<ha-input
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-input>
<ha-input label="With hint" hint="This is a hint"></ha-input>
<ha-input
label="With clear"
with-clear
value="Clear me"
></ha-input>
</div>
<h3>With slots</h3>
<div class="row">
<ha-input label="With prefix">
<span slot="start">$</span>
</ha-input>
<ha-input label="With suffix">
<span slot="end">kg</span>
</ha-input>
<ha-input label="With icon">
<ha-svg-icon .path=${mdiMagnify} slot="start"></ha-svg-icon>
</ha-input>
</div>
<h3>Appearance: outlined</h3>
<div class="row">
<ha-input
appearance="outlined"
label="Outlined"
value="Hello"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined invalid"
invalid
validation-message="Required"
></ha-input>
</div>
<div class="row">
<ha-input
appearance="outlined"
placeholder="Placeholder only"
></ha-input>
</div>
</div>
</ha-card>
<ha-card header="Derivatives in ${mode}">
<div class="card-content">
<h3>ha-input-search</h3>
<ha-input-search label="Search label"></ha-input-search>
<ha-input-search appearance="outlined"></ha-input-search>
<h3>ha-input-copy</h3>
<ha-input-copy
value="my-api-token-123"
masked-value="••••••••••••••••••"
masked-toggle
></ha-input-copy>
<h3>ha-input-multi</h3>
<ha-input-multi
label="URL"
add-label="Add URL"
.value=${["https://example.com"]}
></ha-input-multi>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: block;
}
.panel-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
@@ -237,11 +224,10 @@ export class DemoHaInput extends LitElement {
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
}
.row > * {
flex: 1 1 180px;
flex: 1;
}
`;
}
@@ -1,21 +1,20 @@
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/buttons/ha-progress-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
@customElement("demo-components-ha-progress-button")
export class DemoHaProgressButton extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-progress-button in ${mode}">
<div class="card-content">
<ha-progress-button @click=${this._clickedSuccess}>
Success
@@ -60,17 +59,32 @@ export class DemoHaProgressButton extends LitElement {
</ha-progress-button>
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
private _clickedSuccess(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-progress-button"]>
) {
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
private async _clickedSuccess(ev: CustomEvent): Promise<void> {
console.log("Clicked success");
const button = ev.currentTarget;
const button = ev.currentTarget as any;
button.progress = true;
setTimeout(() => {
@@ -79,10 +93,8 @@ export class DemoHaProgressButton extends LitElement {
}, 1000);
}
private _clickedFail(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-progress-button"]>
) {
const button = ev.currentTarget;
private async _clickedFail(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
setTimeout(() => {
@@ -93,14 +105,20 @@ export class DemoHaProgressButton extends LitElement {
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
+36 -11
View File
@@ -1,12 +1,12 @@
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-bar";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-spinner";
import "../../../../src/components/ha-slider";
import type { HomeAssistant } from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
@customElement("demo-components-ha-slider")
export class DemoHaSlider extends LitElement {
@@ -14,10 +14,10 @@ export class DemoHaSlider extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-slider ${mode} demo">
<div class="card-content">
<span>Default (disabled)</span>
<ha-slider
@@ -45,19 +45,44 @@ export class DemoHaSlider extends LitElement {
></ha-slider>
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
margin: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
+36 -11
View File
@@ -1,11 +1,11 @@
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-bar";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-spinner";
import type { HomeAssistant } from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
@customElement("demo-components-ha-spinner")
export class DemoHaSpinner extends LitElement {
@@ -13,10 +13,10 @@ export class DemoHaSpinner extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-badge ${mode} demo">
<div class="card-content">
<ha-spinner></ha-spinner>
<ha-spinner size="tiny"></ha-spinner>
@@ -27,19 +27,44 @@ export class DemoHaSpinner extends LitElement {
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner>
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
margin: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
+36 -11
View File
@@ -1,10 +1,10 @@
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
@customElement("demo-components-ha-switch")
export class DemoHaSwitch extends LitElement {
@@ -12,10 +12,10 @@ export class DemoHaSwitch extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-switch ${mode}">
<div class="card-content">
<div class="row">
<span>Unchecked</span>
@@ -35,19 +35,44 @@ export class DemoHaSwitch extends LitElement {
</div>
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
margin: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
+34 -21
View File
@@ -1,23 +1,18 @@
import type { TemplateResult } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-textarea";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const LONG_VALUE = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}: this content overflows the max-height and scrolls.`
).join("\n");
@customElement("demo-components-ha-textarea")
export class DemoHaTextarea extends LitElement {
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-textarea in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
@@ -43,11 +38,6 @@ export class DemoHaTextarea extends LitElement {
resize="auto"
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
></ha-textarea>
<ha-textarea
label="Autogrow capped (scrolls past max-height)"
resize="auto"
.value=${LONG_VALUE}
></ha-textarea>
</div>
<h3>States</h3>
@@ -94,19 +84,42 @@ export class DemoHaTextarea extends LitElement {
</div>
</div>
</ha-card>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
.card-content {
display: flex;
+48 -44
View File
@@ -1,19 +1,12 @@
import { provide } from "@lit/context";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-tip";
import { internationalizationContext } from "../../../../src/data/context";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../../../src/data/translation";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const tips: (string | TemplateResult)[] = [
"Test tip",
@@ -21,57 +14,68 @@ const tips: (string | TemplateResult)[] = [
html`<i>Tip</i> <b>with</b> <sub>HTML</sub>`,
];
const localize = (key: string) => key;
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
language: "en",
selectedLanguage: null,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
translationMetadata: { fragments: [], translations: {} },
loadBackendTranslation: async () => localize,
loadFragmentTranslation: async () => localize,
};
@customElement("demo-components-ha-tip")
export class DemoHaTip extends LitElement {
@provide({ context: internationalizationContext })
@state()
protected _i18n = DEMO_I18N;
protected _i18n: HomeAssistantInternationalization = {
localize: ((key: string) => key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
};
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
</div>
</ha-card>
`
)}
</demo-theme-comparison>
`;
return html` ${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-tip ${mode} demo">
<div class="card-content">
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
</div>
</ha-card>
</div>
`
)}`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-tip {
margin-bottom: 14px;
}
ha-card {
margin: 0;
width: 100%;
margin: 24px auto;
}
`;
}
@@ -10,7 +10,7 @@ All pages are stored in [the pages folder][pages-folder] on GitHub. Pages are gr
## Development
You can develop design.home-assistant.io locally by checking out [the Home Assistant frontend repository](https://github.com/home-assistant/frontend). The command to run the gallery is `gallery/script/develop_gallery`. After the first build finishes, the command prints the local URL for the development version of the website.
You can develop design.home-assistant.io locally by checking out [the Home Assistant frontend repository](https://github.com/home-assistant/frontend). The command to run the gallery is `gallery/script/develop_gallery`. It will automatically open a browser window and load the development version of the website.
## Creating a page
+57 -21
View File
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
const SHADOWS = ["s", "m", "l"] as const;
@@ -8,32 +9,67 @@ const SHADOWS = ["s", "m", "l"] as const;
export class DemoMiscBoxShadow extends LitElement {
protected render() {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<div slot=${slot} class="panel-content">
<div class="grid">
${SHADOWS.map(
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
>
${size}
</div>
`
)}
</div>
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<h2>${mode}</h2>
<div class="grid">
${SHADOWS.map(
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
>
${size}
</div>
`
)}
</div>
`
)}
</demo-theme-comparison>
</div>
`
)}
`;
}
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: block;
display: flex;
flex-direction: row;
gap: 48px;
padding: 48px;
}
.light,
.dark {
flex: 1;
background-color: var(--primary-background-color);
border-radius: 16px;
padding: 32px;
}
h2 {
margin: 0 0 24px;
font-size: 18px;
font-weight: 500;
color: var(--primary-text-color);
text-transform: capitalize;
}
.grid {
+16 -16
View File
@@ -29,7 +29,7 @@
"dependencies": {
"@babel/runtime": "7.29.7",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
@@ -40,15 +40,15 @@
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.8",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@formatjs/intl-datetimeformat": "7.4.7",
"@formatjs/intl-displaynames": "7.3.9",
"@formatjs/intl-durationformat": "0.10.13",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.9",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.10",
"@formatjs/intl-pluralrules": "6.3.9",
"@formatjs/intl-relativetimeformat": "12.3.9",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -70,8 +70,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@tsparticles/engine": "4.1.2",
"@tsparticles/preset-links": "4.1.2",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -88,13 +88,13 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"fuse.js": "7.4.1",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.5",
"intl-messageformat": "11.2.8",
"intl-messageformat": "11.2.7",
"js-yaml": "4.2.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -102,7 +102,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.5",
"marked": "18.0.4",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -187,7 +187,7 @@
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.2",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.16",
+11 -1
View File
@@ -4,7 +4,8 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
isCore(page) || isLoadedIntegration(hass, page);
(isCore(page) || isLoadedIntegration(hass, page)) &&
isNotLoadedIntegration(hass, page);
export const isLoadedIntegration = (
hass: HomeAssistant,
@@ -15,4 +16,13 @@ export const isLoadedIntegration = (
isComponentLoaded(hass.config, integration)
);
export const isNotLoadedIntegration = (
hass: HomeAssistant,
page: PageNavigation
) =>
!page.not_component ||
!ensureArray(page.not_component).some((integration) =>
isComponentLoaded(hass.config, integration)
);
export const isCore = (page: PageNavigation) => page.core;
@@ -1,73 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
createQueryString,
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export type HistoryLogbookTargetParamKey =
| "entity_id"
| "label_id"
| "floor_id"
| "area_id"
| "device_id";
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
export type HistoryLogbookBooleanParamKey = "back";
export type HistoryLogbookQueryParams = QueryParamValues<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
"entity_id",
"label_id",
"floor_id",
"area_id",
"device_id",
];
export const historyLogbookQueryParamConfig = {
list: historyLogbookTargetParamKeys,
date: ["start_date", "end_date"],
boolean: [{ key: "back", trueValue: "1" }],
} satisfies QueryParamConfig<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const decodeHistoryLogbookQueryParams = (
searchParams: SearchParamsSource
): HistoryLogbookQueryParams =>
decodeQueryParams(searchParams, historyLogbookQueryParamConfig);
export const historyLogbookTargetFromQueryParams = (
params: HistoryLogbookQueryParams
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
startDate: Date,
endDate: Date
): string => {
const queryString = createQueryString(
{
...queryParamsFromServiceTarget(target, historyLogbookTargetParamKeys),
start_date: startDate,
end_date: endDate,
},
historyLogbookQueryParamConfig
);
return queryString ? `${path}?${queryString}` : path;
};
-140
View File
@@ -1,140 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../array/ensure-array";
export type SearchParamsSource =
| URLSearchParams
| Record<string, string>
| string;
export interface QueryParamConfig<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> {
list?: readonly ListKey[];
date?: readonly DateKey[];
boolean?: readonly {
key: BooleanKey;
trueValue: string;
}[];
}
export type QueryParamValues<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> = Partial<
Record<ListKey, string[]> &
Record<DateKey, Date> &
Record<BooleanKey, boolean>
>;
export type ServiceTargetQueryParams<
Key extends keyof HassServiceTarget & string,
> = Partial<Record<Key, string[]>>;
const getSearchParam = (
searchParams: SearchParamsSource,
key: string
): string | null => {
if (typeof searchParams === "string") {
return new URLSearchParams(searchParams).get(key);
}
if (searchParams instanceof URLSearchParams) {
return searchParams.get(key);
}
return searchParams[key] ?? null;
};
export const decodeQueryParams = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
searchParams: SearchParamsSource,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",") as (typeof params)[typeof key];
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value) as (typeof params)[typeof key];
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true as (typeof params)[typeof key];
}
}
return params;
};
export const createQueryString = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): string => {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key] as string[] | undefined;
if (value?.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key] as Date | undefined;
if (value) {
searchParams.append(key, value.toISOString());
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (values[key]) {
searchParams.append(key, trueValue);
}
}
return searchParams.toString();
};
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
>(
params: ServiceTargetQueryParams<Key>,
keys: readonly Key[]
): HassServiceTarget | undefined => {
if (!keys.some((key) => params[key])) {
return undefined;
}
const target: HassServiceTarget = {};
for (const key of keys) {
const value = params[key];
if (value) {
target[key] = value;
}
}
return target;
};
export const queryParamsFromServiceTarget = <
Key extends keyof HassServiceTarget & string,
>(
target: HassServiceTarget,
keys: readonly Key[]
): ServiceTargetQueryParams<Key> => {
const params: ServiceTargetQueryParams<Key> = {};
for (const key of keys) {
const value = target[key];
if (value) {
params[key] = ensureArray(value);
}
}
return params;
};
@@ -32,10 +32,10 @@ export class HaAutomationRowLiveTest extends LitElement {
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
display: inline-flex;
align-items: center;
vertical-align: middle;
margin-inline-start: var(--ha-space-1);
}
#indicator {
width: 10px;
+15 -32
View File
@@ -162,7 +162,7 @@ export class HaDataTable extends LitElement {
@state() private _filter = "";
@state() private _filteredData?: DataTableRowData[];
@state() private _filteredData: DataTableRowData[] = [];
@state() private _headerHeight = 0;
@@ -204,7 +204,7 @@ export class HaDataTable extends LitElement {
}
public selectAll(): void {
this._checkedRows = (this._filteredData || [])
this._checkedRows = this._filteredData
.filter((data) => data.selectable !== false)
.map((data) => data[this.id]);
this._lastSelectedRowId = null;
@@ -216,7 +216,7 @@ export class HaDataTable extends LitElement {
this._checkedRows = [];
}
ids.forEach((id) => {
const row = this._filteredData?.find((data) => data[this.id] === id);
const row = this._filteredData.find((data) => data[this.id] === id);
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
this._checkedRows.push(id);
}
@@ -238,7 +238,7 @@ export class HaDataTable extends LitElement {
public connectedCallback() {
super.connectedCallback();
if (this._filteredData?.length) {
if (this._filteredData.length) {
// Force update of location of rows
this._filteredData = [...this._filteredData];
}
@@ -366,10 +366,7 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
if (
this._filteredData &&
(properties.has("selectable") || properties.has("hiddenColumns"))
) {
if (properties.has("selectable") || properties.has("hiddenColumns")) {
this._filteredData = [...this._filteredData];
}
}
@@ -412,8 +409,6 @@ export class HaDataTable extends LitElement {
const renderRow = (row: DataTableRowData, index: number) =>
this._renderRow(columns, this.narrow, row, index);
const filteredDataLength = this._filteredData?.length || 0;
return html`
<div class="mdc-data-table">
<slot name="header" @slotchange=${this._calcTableHeight}>
@@ -434,10 +429,10 @@ export class HaDataTable extends LitElement {
"auto-height": this.autoHeight,
})}"
role="table"
aria-rowcount=${filteredDataLength + 1}
aria-rowcount=${this._filteredData.length + 1}
style=${styleMap({
height: this.autoHeight
? `${(filteredDataLength || 1) * 53 + 53}px`
? `${(this._filteredData.length || 1) * 53 + 53}px`
: `calc(100% - ${this._headerHeight}px)`,
})}
>
@@ -526,23 +521,16 @@ export class HaDataTable extends LitElement {
})}
</slot>
</div>
${!this._filteredData?.length
${!this._filteredData.length
? html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div class="mdc-data-table__cell grows center" role="cell">
${!this._filteredData
? this._i18n?.localize?.("ui.common.loading") ||
"Loading"
: this.data.length
? this._i18n?.localize?.(
"ui.components.data-table.no_match_filter"
) || "No rows matching current filters"
: this.noDataText ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"}
${this.noDataText ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"}
</div>
</div>
</div>
@@ -915,7 +903,7 @@ export class HaDataTable extends LitElement {
const rowId = checkboxElement.rowId;
const groupedData = this._groupData(
this._filteredData || [],
this._filteredData,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
@@ -1017,7 +1005,7 @@ export class HaDataTable extends LitElement {
private _checkedRowsChanged() {
// force scroller to update, change it's items
if (this._filteredData?.length) {
if (this._filteredData.length) {
this._filteredData = [...this._filteredData];
}
fireEvent(this, "selection-changed", {
@@ -1477,11 +1465,6 @@ export class HaDataTable extends LitElement {
.mdc-data-table__table.auto-height .scroller {
overflow-y: hidden !important;
}
.mdc-data-table__table.auto-height lit-virtualizer {
overscroll-behavior-y: auto;
}
.grows {
flex-grow: 1;
flex-shrink: 1;
@@ -115,20 +115,6 @@ export class HaEntityStatePicker extends LitElement {
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
private _computeDefaultLabel(): string {
// When an attribute is configured, default to the attribute's friendly
// name (e.g. "Source") instead of the generic "State". Requires a concrete
// entity to resolve the translated name; otherwise fall back to "State".
if (this.attribute && this.entityId) {
const entityId = ensureArray(this.entityId)[0];
const stateObj = entityId ? this.hass.states[entityId] : undefined;
if (stateObj) {
return this.hass.formatEntityAttributeName(stateObj, this.attribute);
}
}
return this.hass.localize("ui.components.entity.entity-state-picker.state");
}
protected render() {
if (!this.hass) {
return nothing;
@@ -143,7 +129,8 @@ export class HaEntityStatePicker extends LitElement {
.disabled=${this.disabled || noEntity}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ?? this._computeDefaultLabel()}
.label=${this.label ??
this.hass.localize("ui.components.entity.entity-state-picker.state")}
.helper=${this.helper}
.value=${this.value}
.getItems=${this._getFilteredItems}
+4 -4
View File
@@ -1600,8 +1600,8 @@ export class HaCodeEditor extends ReactiveElement {
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.displayLabel
?.toLowerCase()
entityState.label
.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
@@ -1658,8 +1658,8 @@ export class HaCodeEditor extends ReactiveElement {
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.displayLabel
?.toLowerCase()
entityState.label
.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
+14 -34
View File
@@ -170,9 +170,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@property({ attribute: "always-expand", type: Boolean })
public alwaysExpand = false;
@property({ attribute: "sidebar-title" }) public sidebarTitle =
"Home Assistant";
@state() private _notifications?: PersistentNotification[];
@state() private _updatesCount = 0;
@@ -349,8 +346,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@action=${this._toggleSidebar}
></ha-icon-button>
`
: nothing}
<div class="title">${this.sidebarTitle}</div>
: ""}
<div class="title">Home Assistant</div>
</div>`;
}
@@ -365,28 +362,16 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>`;
if (!this._panelOrder || !this._hiddenPanels) {
return html`<div class="panels-list">
<div class="wrapper">
${renderList(
html`<slot name="main-navigation">
<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>
</slot>`,
"before-spacer",
true
)}
${this.renderScrollableFades()}
</div>
${this._renderSpacer()}
return html`
<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>
${renderList(
html`<slot name="fixed-navigation">
${this._renderFixedPanels(selectedPanel)}
</slot>`,
html`${this._renderFixedPanels(selectedPanel)}`,
"after-spacer",
false
)}
</div>`;
`;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
@@ -403,9 +388,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
return html`<div class="panels-list">
<div class="wrapper">
${renderList(
html`<slot name="main-navigation">
${this._renderPanels(beforeSpacer, selectedPanel)}
</slot>`,
this._renderPanels(beforeSpacer, selectedPanel),
"before-spacer",
true
)}
@@ -413,10 +396,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
</div>
${this._renderSpacer()}
${renderList(
html`<slot name="fixed-navigation">
html`
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderFixedPanels(selectedPanel)}
</slot>`,
`,
"after-spacer",
false
)}
@@ -558,7 +541,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>
<ha-user-badge slot="start" .user=${this.hass.user}></ha-user-badge>
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : nothing}</span
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-list-item-button>
${!this.alwaysExpand && this.hass.user
@@ -682,10 +665,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
transition: width var(--ha-animation-duration-normal) ease;
}
:host([expanded]) .menu {
width: calc(
var(--ha-sidebar-expanded-width, 256px) +
var(--safe-area-inset-left, 0px)
);
width: calc(256px + var(--safe-area-inset-left, 0px));
}
:host([narrow][expanded]) .menu {
width: 100%;
@@ -768,7 +748,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
color: var(--sidebar-text-color);
}
:host([expanded]) ha-list-item-button {
width: var(--ha-sidebar-expanded-item-width, 248px);
width: 248px;
}
:host([narrow][expanded]) ha-list-item-button {
width: calc(240px - var(--safe-area-inset-left, 0px));
-8
View File
@@ -258,14 +258,6 @@ export class HaTextArea extends WaInputMixin(LitElement) {
overflow-y: auto;
}
/* The size-adjuster shares a grid cell with the textarea and is given an
inline height matching the content's scrollHeight. Without capping it
too, it inflates the grid row past the max-height and pushes the
textarea down instead of scrolling. */
:host([resize="auto"]) wa-textarea::part(textarea-adjuster) {
max-height: var(--ha-textarea-max-height, 200px);
}
wa-textarea:hover::part(base),
wa-textarea:hover::part(label) {
background-color: var(--ha-color-form-background-hover);
-255
View File
@@ -1,255 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { normalizeLuminance } from "../common/color/palette";
import { fireEvent } from "../common/dom/fire_event";
import {
DefaultAccentColor,
DefaultPrimaryColor,
} from "../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./ha-settings-row";
import "./ha-theme-picker";
import "./input/ha-input";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
const HOME_ASSISTANT_THEME = "default";
export interface ThemeSettingsLabels {
theme?: string;
noTheme?: string;
mode?: string;
autoMode?: string;
lightMode?: string;
darkMode?: string;
primaryColor?: string;
accentColor?: string;
reset?: string;
}
@customElement("ha-theme-settings")
export class HaThemeSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selectedTheme?: ThemeSettings | null;
@property({ attribute: false }) public labels?: ThemeSettingsLabels;
@property({ attribute: false }) public description?: TemplateResult | string;
@property() public heading?: string;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@property({ attribute: "show-theme-picker", type: Boolean })
public showThemePicker = true;
@property({ attribute: "theme-picker-disabled", type: Boolean })
public themePickerDisabled = false;
protected render(): TemplateResult {
const themeSettings = this.selectedTheme ?? this.hass.selectedTheme;
const curThemeIsUseDefault = themeSettings?.theme === "";
const curTheme = themeSettings?.theme
? themeSettings.theme
: this.hass.themes.darkMode
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
return html`
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
${this.heading
? html`<span slot="heading">${this.heading}</span>`
: nothing}
${this.description
? html`<span slot="description">${this.description}</span>`
: nothing}
${this.showThemePicker
? html`
<ha-theme-picker
.hass=${this.hass}
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
`
: nothing}
</ha-settings-row>
${curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
<ha-radio-group
@change=${this._handleDarkMode}
name="dark_mode"
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
.value=${themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
${curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
${themeSettings?.primaryColor || themeSettings?.accentColor
? html` <ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>`
: nothing}
</div>`
: nothing}
</div>`
: nothing}
`;
}
private _handleColorChange(ev: Event) {
const target = ev.currentTarget as HTMLInputElement;
const value =
target.name === "primaryColor"
? normalizeLuminance(target.value)
: target.value;
target.value = value;
fireEvent(this, "theme-settings-changed", {
[target.name]: value,
} as Partial<ThemeSettings>);
}
private _resetColors() {
fireEvent(this, "theme-settings-changed", {
primaryColor: undefined,
accentColor: undefined,
});
}
private _supportsModeSelection(themeName: string): boolean {
const theme = this.hass.themes.themes[themeName];
if (!theme) {
return false;
}
return !!(theme.modes && "light" in theme.modes && "dark" in theme.modes);
}
private _handleDarkMode(ev: Event) {
let dark: boolean | undefined;
switch ((ev.currentTarget as HaRadioGroup).value) {
case "light":
dark = false;
break;
case "dark":
dark = true;
break;
}
fireEvent(this, "theme-settings-changed", { dark });
}
private _handleThemeSelection(
ev: ValueChangedEvent<string | undefined>
): void {
ev.stopPropagation();
const theme = ev.detail.value;
if (theme === undefined) {
if (this.selectedTheme?.theme || this.hass.selectedTheme?.theme) {
fireEvent(this, "theme-settings-changed", {
theme: "",
primaryColor: undefined,
accentColor: undefined,
});
}
return;
}
if (theme === (this.selectedTheme ?? this.hass.selectedTheme)?.theme) {
return;
}
fireEvent(this, "theme-settings-changed", {
theme,
primaryColor: undefined,
accentColor: undefined,
});
}
static styles = css`
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 var(--ha-space-3);
}
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
}
.color-pickers {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 var(--ha-space-1);
}
ha-theme-picker {
display: block;
width: 100%;
}
`;
}
declare global {
interface HASSDomEvents {
"theme-settings-changed": Partial<ThemeSettings>;
}
interface HTMLElementTagNameMap {
"ha-theme-settings": HaThemeSettings;
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ export class HaListVirtualized extends HaListBase {
this._activeItemFocus = focusItem;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(this.activeItemIndex)
?.element(index)
?.scrollIntoView({ block: "nearest" });
}
}
-31
View File
@@ -1,31 +0,0 @@
import { createContext } from "@lit/context";
export const DEFAULT_DIRTY_STATE_KEY = "__default__";
export type DefaultDirtyStateKey = typeof DEFAULT_DIRTY_STATE_KEY;
export interface DirtyStateContext<
State = unknown,
Key extends string = DefaultDirtyStateKey,
> {
/** Whether any contributor's current slice differs from its initial snapshot */
isDirty: boolean;
/**
* Push a state slice. The first push for a slice sets its baseline.
* Subsequent pushes are compared against that baseline using the provider's
* compare strategy.
*/
setState: (state: State, key: Key) => void;
/** Reset every slice baseline to its current value (marks clean). */
markClean: () => void;
}
/**
* Singleton context key for dirty-state tracking.
*
* Because Lit context keys are singletons, the value type is
* `DirtyStateContext<unknown, DefaultDirtyStateKey>`. Providers and consumers
* can use narrower `DirtyStateContext<State, Key>` annotations at the type
* boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
@@ -10,21 +10,13 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
class DialogConfigEntrySystemOptions extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -46,13 +38,6 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -83,7 +68,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
) || this._params.entry.domain,
}
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -150,7 +135,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -164,19 +149,11 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
@@ -116,14 +116,12 @@ class MoreInfoMediaPlayer extends LitElement {
MediaPlayerEntityFeature.VOLUME_SET
);
const assumedState = this.stateObj.attributes.assumed_state === true;
return html`${(supportsFeature(
this.stateObj!,
MediaPlayerEntityFeature.VOLUME_SET
) ||
supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) &&
(stateActive(this.stateObj!) || assumedState)
stateActive(this.stateObj!)
? html`
<div class="volume">
${supportsMute
+56 -78
View File
@@ -63,9 +63,6 @@ import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
import type { Helper } from "../../panels/config/helpers/const";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
@@ -124,10 +121,9 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | null,
"entity-registry" | "helper"
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
export class MoreInfoDialog extends SubscribeMixin(
ScrollableFadeMixin(LitElement)
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -637,18 +633,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this.hass.translationMetadata.translations
);
const childViewContent = this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: nothing;
return html`
<ha-adaptive-dialog
.open=${this._open}
@@ -656,8 +640,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@show-child-view=${this._showChildView}
.preventScrimClose=${(this._currView === "settings" &&
this.isDirtyState) ||
.preventScrimClose=${this._currView === "settings" ||
!this._isEscapeEnabled}
flexcontent
>
@@ -880,65 +863,70 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${this._currView === "settings"
? html`
<div ?hidden=${!!this._childView}>
<ha-more-info-settings
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
</div>
${childViewContent}
`
: cache(
this._childView
? childViewContent
: this._currView === "info"
${cache(
this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: this._currView === "info"
? html`
<ha-more-info-info
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
.data=${this._data}
></ha-more-info-info>
`
: this._currView === "history"
? html`
<ha-more-info-history-and-logbook
.hass=${this.hass}
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
`
: this._currView === "settings"
? html`
<ha-more-info-info
<ha-more-info-settings
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
.data=${this._data}
></ha-more-info-info>
></ha-more-info-settings>
`
: this._currView === "history"
: this._currView === "related"
? html`
<ha-more-info-history-and-logbook
<ha-related-items
.hass=${this.hass}
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
`
: this._currView === "related"
: this._currView === "add_to"
? html`
<ha-related-items
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
<ha-more-info-add-to
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
`
: this._currView === "add_to"
: this._currView === "details"
? html`
<ha-more-info-add-to
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
<ha-more-info-details
.hass=${this.hass}
.entry=${this._entry}
.params=${{ entityId }}
.yamlMode=${this._detailsYamlMode}
></ha-more-info-details>
`
: this._currView === "details"
? html`
<ha-more-info-details
.hass=${this.hass}
.entry=${this._entry}
.params=${{ entityId }}
.yamlMode=${this._detailsYamlMode}
></ha-more-info-details>
`
: nothing
)}
: nothing
)}
</div>
`
)}
@@ -961,10 +949,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
| MoreInfoView
| undefined;
if (previousView === "settings" && this._currView !== "settings") {
this._discardDirtyStateChanges();
}
if (previousView === "details" && this._currView !== "details") {
const dialog =
this._dialogElement?.shadowRoot?.querySelector("ha-dialog");
@@ -973,12 +957,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
}
if (changedProps.has("_currView") || changedProps.has("_entry")) {
if (this._currView === "settings" && this._entry) {
this._initDirtyTracking({ type: "deep" });
}
}
if (changedProps.has("_currView")) {
this._infoEditMode = false;
this._detailsYamlMode = false;
@@ -5,7 +5,6 @@ in core bundle slows things down and causes duplicate registration.
This is the entry point for providing external app stuff from app entrypoint.
*/
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
@@ -16,7 +15,6 @@ import type {
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageCommands,
ImprovDiscoveredDevice,
MatterCommissionFinish,
} from "./external_messaging";
const barCodeListeners = new Set<
@@ -93,8 +91,6 @@ export const handleExternalMessage = (
fireEvent(window, "improv-discovered-device", msg.payload);
} else if (msg.command === "improv/device_setup_done") {
fireEvent(window, "improv-device-setup-done");
} else if (msg.command === "matter/commission/finish") {
fireEvent(window, "matter-commission-finish", msg.payload);
} else if (msg.command === "bar_code/scan_result") {
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "bar_code/aborted") {
@@ -119,10 +115,5 @@ declare global {
interface HASSDomEvents {
"improv-discovered-device": ImprovDiscoveredDevice;
"improv-device-setup-done": undefined;
"matter-commission-finish": MatterCommissionFinish;
}
interface GlobalEventHandlersEventMap {
"matter-commission-finish": HASSDomEvent<MatterCommissionFinish>;
}
}
-14
View File
@@ -320,18 +320,6 @@ export interface EMIncomingMessageKioskModeSet {
};
}
export interface MatterCommissionFinish {
name: string | null;
success: boolean;
}
export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
id: number;
type: "command";
command: "matter/commission/finish";
payload: MatterCommissionFinish;
}
export type EMIncomingMessageCommands =
| EMIncomingMessageRestart
| EMIncomingMessageNavigate
@@ -343,7 +331,6 @@ export type EMIncomingMessageCommands =
| EMIncomingMessageBarCodeScanAborted
| EMIncomingMessageImprovDeviceDiscovered
| EMIncomingMessageImprovDeviceSetupDone
| EMIncomingMessageMatterCommissionFinish
| EMIncomingMessageKioskModeSet;
type EMIncomingMessage =
@@ -359,7 +346,6 @@ export interface ExternalConfig {
canWriteTag?: boolean;
hasExoPlayer?: boolean;
canCommissionMatter?: boolean;
hasMatterStatusReport?: boolean;
canImportThreadCredentials?: boolean;
canTransferThreadCredentialsToKeychain?: boolean;
hasAssist?: boolean;
-12
View File
@@ -6,7 +6,6 @@ import {
import { fireEvent } from "../common/dom/fire_event";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import type { IconCategory } from "../data/icons";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import {
DateFormat,
@@ -21,7 +20,6 @@ import { getLocalLanguage, getTranslation } from "../util/common-translation";
import { demoConfig } from "./demo_config";
import { demoPanels } from "./demo_panels";
import { demoServices } from "./demo_services";
import { ENTITY_COMPONENT_ICONS } from "./entity_component_icons";
import { getEntity } from "./entities/registry";
import type { EntityInput } from "./entities/types";
@@ -35,12 +33,6 @@ type MockRestCallback = (
parameters: Record<string, any> | undefined
) => any;
interface MockGetIconsMessage {
type: "frontend/get_icons";
category: IconCategory;
integration?: string;
}
export interface MockHomeAssistant extends HomeAssistant {
mockEntities: any;
updateHass(obj: Partial<MockHomeAssistant>);
@@ -423,10 +415,6 @@ export const provideHass = (
...overrideData,
};
hassObj.mockWS("frontend/get_icons", ({ category }: MockGetIconsMessage) => ({
resources: category === "entity_component" ? ENTITY_COMPONENT_ICONS : {},
}));
// Set hass if required
if (setHassProperty) {
elements.forEach((el) => {
+1
View File
@@ -33,6 +33,7 @@ export interface PageNavigation {
translationKey?: string;
component?: string | string[];
name?: string;
not_component?: string | string[];
core?: boolean;
/** Hide from non-admin users in filtered navigation and quick bar. */
adminOnly?: boolean;
-190
View File
@@ -1,190 +0,0 @@
import { provide } from "@lit/context";
import deepClone from "deep-clone-simple";
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
DEFAULT_DIRTY_STATE_KEY,
dirtyStateContext,
type DefaultDirtyStateKey,
type DirtyStateContext,
} from "../data/context/dirty-state";
import type { Constructor } from "../types";
export type CompareStrategy<State> =
| { type: "deep" }
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
/**
* Mixin that provides dirty-state tracking via Lit context.
*
* The provider holds a map of named slices. Each slice has its own initial
* snapshot and current value, and is compared with the configured compare
* strategy. `isDirty` is true when any slice differs from its initial value,
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* @example Eager init for the provider's own slice:
* ```ts
* class MyDialog extends DirtyStateProviderMixin<MyDialogState>()(LitElement) {
* open() {
* this._initDirtyTracking({ type: "shallow" }, { name: "", icon: "" });
* // Update later with `this._updateDirtyState({ name, icon })`.
* }
* }
* ```
*
* @example Deferred init with child consumers:
* ```ts
* class MyPage extends DirtyStateProviderMixin<MyState, "their-key">()(LitElement) {
* connectedCallback() {
* super.connectedCallback();
* this._initDirtyTracking({ type: "deep" });
* // Child consumers push slices via `setState(value, "their-key")`.
* }
* }
* ```
*
* Child consumers:
* ```ts
* @consume({ context: dirtyStateContext, subscribe: true })
* @state()
* private _dirtyState?: DirtyStateContext<MyState, "my-section">;
*
* // Read: this._dirtyState?.isDirty
* // Write: this._dirtyState?.setState(value, "my-section")
* ```
*/
export const DirtyStateProviderMixin =
<State = unknown, Key extends string = DefaultDirtyStateKey>() =>
<Base extends Constructor<LitElement>>(superClass: Base) => {
class DirtyStateProviderMixinClass extends superClass {
private _dirtySlices = new Map<
Key | DefaultDirtyStateKey,
{ initial: State; current: State }
>();
private _dirtyCompareFn: (a: State, b: State) => boolean = deepEqual;
private _dirtyCloneFn: (value: State) => State = (value) => value;
@provide({ context: dirtyStateContext })
@state()
private _dirtyStateContext: DirtyStateContext<State, Key> =
this._buildContextValue();
private _buildContextValue(): DirtyStateContext<State, Key> {
return {
isDirty: Array.from(this._dirtySlices.values()).some(
({ initial, current }) => !this._dirtyCompareFn(initial, current)
),
setState: (value: State, key: Key) => {
this._writeSlice(key, value);
},
markClean: () => {
this._markDirtyStateClean();
},
};
}
private _publishContext(): void {
this._dirtyStateContext = this._buildContextValue();
}
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
const slice = this._dirtySlices.get(key);
if (!slice) {
// First push for this key becomes the baseline.
this._dirtySlices.set(key, {
initial: this._dirtyCloneFn(value),
current: value,
});
this._publishContext();
return;
}
if (this._dirtyCompareFn(slice.current, value)) {
return;
}
slice.current = value;
this._publishContext();
}
/**
* Initialize dirty state tracking.
*
* When `initialState` is provided, it seeds the provider's own slice so
* `_updateDirtyState` can be used immediately. When omitted, the first
* push for any key (via the provider helper or a consumer's `setState`)
* becomes that key's baseline.
*
* Call again to reset (e.g. when the underlying entity changes).
*/
protected _initDirtyTracking(
strategy: CompareStrategy<State>,
initialState?: State
): void {
switch (strategy.type) {
case "deep":
this._dirtyCompareFn = (a, b) => deepEqual(a, b);
this._dirtyCloneFn = (value) => deepClone(value);
break;
case "shallow":
this._dirtyCompareFn = (a, b) => shallowEqual(a, b);
this._dirtyCloneFn = (value) => value;
break;
default:
this._dirtyCompareFn = strategy.compare;
this._dirtyCloneFn = (value) => value;
}
this._dirtySlices.clear();
if (initialState !== undefined) {
this._dirtySlices.set(DEFAULT_DIRTY_STATE_KEY, {
initial: this._dirtyCloneFn(initialState),
current: initialState,
});
}
this._publishContext();
}
/**
* Update the provider's own state slice. Triggers dirty comparison
* against the provider's baseline (or sets the baseline if this is the
* first push after a deferred init).
*/
protected _updateDirtyState(newState: State): void {
this._writeSlice(DEFAULT_DIRTY_STATE_KEY, newState);
}
/**
* Reset every slice's baseline to its current value. Call this after a
* successful save.
*/
protected _markDirtyStateClean(): void {
for (const slice of this._dirtySlices.values()) {
slice.initial = this._dirtyCloneFn(slice.current);
}
this._publishContext();
}
/**
* Discard pending changes by restoring each slice's current value back
* to its baseline.
*/
protected _discardDirtyStateChanges(): void {
for (const slice of this._dirtySlices.values()) {
slice.current = this._dirtyCloneFn(slice.initial);
}
this._publishContext();
}
/**
* Whether any slice's current value differs from its baseline.
*/
public get isDirtyState(): boolean {
return this._dirtyStateContext.isDirty;
}
}
return DirtyStateProviderMixinClass;
};
@@ -23,28 +23,18 @@ import {
} from "../../../data/application_credential";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface CredentialFormState {
domain: string;
name: string;
clientId: string;
clientSecret: string;
}
interface Domain {
id: string;
name: string;
}
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -86,7 +76,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
this._error = undefined;
this._loading = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, this._currentState());
this._fetchConfig();
}
@@ -111,7 +100,10 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
<ha-dialog
.open=${this._open}
@closed=${this._abortDialog}
.preventScrimClose=${this.isDirtyState}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)}
@@ -292,7 +284,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
ev.stopPropagation();
this._domain = ev.detail.value;
this._updateDescription();
this._updateDirtyState(this._currentState());
}
private async _updateDescription() {
@@ -316,16 +307,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
this._updateDirtyState(this._currentState());
}
private _currentState(): CredentialFormState {
return {
domain: this._domain || "",
name: this._name || "",
clientId: this._clientId || "",
clientSecret: this._clientSecret || "",
};
}
private _abortDialog() {
@@ -32,7 +32,6 @@ import {
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { ObjectSelector, Selector } from "../../../../../data/selector";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@@ -57,15 +56,15 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
const MASKED_FIELDS = ["password", "secret", "token"];
@customElement("supervisor-app-config")
class SupervisorAppConfig extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
class SupervisorAppConfig extends LitElement {
@property({ attribute: false }) public addon!: HassioAddonDetails;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@state() private _configHasChanged = false;
@state() private _valid = true;
@state() private _canShowSchema = false;
@@ -352,7 +351,9 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
<div class="card-actions right">
<ha-progress-button
@click=${this._saveTapped}
.disabled=${this.disabled || !this.isDirtyState || !this._valid}
.disabled=${this.disabled ||
!this._configHasChanged ||
!this._valid}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -376,7 +377,6 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("addon")) {
this._options = { ...this.addon.options };
this._initDirtyTracking({ type: "deep" }, this.addon.options);
}
super.updated(changedProperties);
if (
@@ -415,13 +415,11 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
private _configChanged(ev): void {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true;
this._configHasChanged = true;
this._options = ev.detail.value;
this._updateDirtyState(ev.detail.value);
} else {
this._configHasChanged = true;
this._valid = ev.detail.isValid;
if (ev.detail.isValid) {
this._updateDirtyState(ev.detail.value);
}
}
}
@@ -452,7 +450,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -471,7 +469,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (this.disabled || !this.isDirtyState || !this._valid) {
if (this.disabled || !this._configHasChanged || !this._valid) {
return;
}
@@ -501,7 +499,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
options,
});
this._markDirtyStateClean();
this._configHasChanged = false;
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -15,16 +15,13 @@ import type {
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
class SupervisorAppNetwork extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -33,19 +30,19 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
@state() private _showOptional = false;
@state() private _configHasChanged = false;
@state() private _error?: string;
@state() private _config?: Record<string, number | null>;
@state() private _config?: Record<string, any>;
protected render() {
if (!this._config) {
return nothing;
}
const config = this._config;
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
const hasHiddenOptions = Object.keys(this._config).find(
(entry) => this._config![entry] === null
);
return html`
@@ -101,7 +98,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
</ha-progress-button>
<ha-progress-button
@click=${this._saveTapped}
.disabled=${!this.isDirtyState || this.disabled}
.disabled=${!this._configHasChanged || this.disabled}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -118,10 +115,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private _createSchema = memoizeOne(
(
config: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(config: Record<string, number>, showOptional: boolean): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
@@ -147,14 +141,12 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
item.name;
private _setNetworkConfig(): void {
const config = this.addon.network || {};
this._config = config;
this._initDirtyTracking({ type: "shallow" }, config);
this._config = this.addon.network || {};
}
private _configChanged(ev: CustomEvent): void {
private async _configChanged(ev: CustomEvent): Promise<void> {
this._configHasChanged = true;
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -169,7 +161,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -196,14 +188,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (!this.isDirtyState || this.disabled) {
if (!this._configHasChanged || this.disabled) {
return;
}
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration: Record<string, number | null> = {};
const networkconfiguration = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
@@ -214,7 +206,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -45,6 +45,7 @@ class SupervisorAppsState extends LitElement {
}
.dot.state-started {
background-color: var(--ha-color-green-80);
animation: state-dot-pulse 1.8s infinite;
}
.dot.state-startup {
background-color: var(--ha-color-on-warning-normal);
@@ -55,6 +56,19 @@ class SupervisorAppsState extends LitElement {
ha-svg-icon {
--mdc-icon-size: 20px;
}
@keyframes state-dot-pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--rgb-success-color), 0.6);
}
100% {
box-shadow: 0 0 0 6px rgba(var(--rgb-success-color), 0);
}
}
@media (prefers-reduced-motion) {
.dot.state-started {
animation: none;
}
}
`;
}
@@ -217,19 +217,11 @@ export default class HaAutomationConditionRow extends LitElement {
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`
: nothing}
</div>
${this.optionsInSidebar &&
this.condition.condition !== "trigger" &&
this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
? html`<ha-tooltip for="condition-live-test" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
@@ -245,6 +237,15 @@ export default class HaAutomationConditionRow extends LitElement {
this.condition.condition !== "device"
)
: nothing}
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-row-live-test
id="condition-live-test"
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`
: nothing}
${this.condition.note?.trim()
? html`
<ha-svg-icon
@@ -15,19 +15,13 @@ import type {
} from "../../../data/category_registry";
import { internationalizationContext } from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import type { CategoryRegistryDetailDialogParams } from "./show-dialog-category-registry-detail";
interface CategoryFormState {
name: string;
icon: string | null;
}
@customElement("dialog-category-registry-detail")
class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
DialogMixin<CategoryRegistryDetailDialogParams>(LitElement)
class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -50,10 +44,6 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
this._name = this.params?.suggestedName || "";
this._icon = null;
}
this._initDirtyTracking(
{ type: "shallow" },
{ name: this._name, icon: this._icon }
);
}
protected render() {
@@ -62,14 +52,13 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
}
const entry = this.params.entry;
const nameInvalid = !this._isNameValid();
const isCreate = !entry;
return html`
<ha-dialog
open
header-title=${entry
? this._i18n.localize("ui.panel.config.category.editor.edit")
: this._i18n.localize("ui.panel.config.category.editor.create")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -107,9 +96,7 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid ||
!!this._submitting ||
(!isCreate && !this.isDirtyState)}
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this._i18n.localize("ui.common.save")
@@ -127,17 +114,15 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState({ name: this._name, icon: this._icon });
}
private _iconChanged(ev: ValueChangedEvent<string>) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState({ name: this._name, icon: this._icon });
}
private async _updateEntry() {
const create = !this.params?.entry;
const create = !this.params!.entry;
this._submitting = true;
let newValue: CategoryRegistryEntry | undefined;
try {
@@ -146,11 +131,10 @@ class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
icon: this._icon || (create ? undefined : null),
};
if (create) {
newValue = await this.params?.createEntry?.(values);
newValue = await this.params!.createEntry!(values);
} else {
newValue = await this.params?.updateEntry?.(values);
newValue = await this.params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -10,6 +10,7 @@ import type { DeviceRegistryEntry } from "../../../../../../data/device/device_r
import { fetchZHADevice } from "../../../../../../data/zha";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../../types";
import { showZHAManageZigbeeDeviceDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-manage-zigbee-device";
import { showZHAReconfigureDeviceDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-reconfigure-device";
import type { DeviceAction } from "../../../ha-config-device-page";
@@ -63,7 +64,8 @@ export const getZHADeviceActions = async (
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.manage"),
icon: mdiGroup,
action: () => navigate(`/config/zha/device/${zhaDevice.ieee}/clusters`),
action: () =>
showZHAManageZigbeeDeviceDialog(el, { device: zhaDevice }),
},
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.view_network"),
@@ -1,22 +1,17 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consume } from "@lit/context";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeEntityEntryName } from "../../../../../common/entity/compute_entity_name";
import "../../../../../components/ha-button";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../../../data/context/dirty-state";
import type { ExtEntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import { removeEntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import { HELPERS_CRUD } from "../../../../../data/helpers_crud";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import type { HomeAssistant } from "../../../../../types";
import type { Helper } from "../../../helpers/const";
import "../../../helpers/forms/ha-counter-form";
import "../../../helpers/forms/ha-input_boolean-form";
@@ -38,21 +33,21 @@ export class EntitySettingsHelperTab extends LitElement {
@property({ attribute: false }) public entry!: ExtEntityRegistryEntry;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext<Helper | null, "helper">;
@state() private _error?: string;
@state() private _item?: Helper | null;
@state() private _submitting = false;
@state() private _dirty = false;
@state() private _componentLoaded?: boolean;
@query("entity-registry-settings-editor")
private _registryEditor?: EntityRegistrySettingsEditor;
private _originalItemJson?: string;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this._componentLoaded = isComponentLoaded(
@@ -65,9 +60,13 @@ export class EntitySettingsHelperTab extends LitElement {
super.updated(changedProperties);
if (changedProperties.has("entry")) {
this._error = undefined;
if (this.entry.unique_id !== changedProperties.get("entry")?.unique_id) {
if (
this.entry.unique_id !==
(changedProperties.get("entry") as ExtEntityRegistryEntry)?.unique_id
) {
this._item = undefined;
}
this._getItem();
}
}
@@ -108,6 +107,7 @@ export class EntitySettingsHelperTab extends LitElement {
.hass=${this.hass}
.entry=${this.entry}
.disabled=${!!this._submitting}
@change=${this._entityRegistryChanged}
hide-name
hide-icon
></entity-registry-settings-editor>
@@ -124,7 +124,7 @@ export class EntitySettingsHelperTab extends LitElement {
</ha-button>
<ha-button
@click=${this._updateItem}
.disabled=${!this._dirtyState?.isDirty ||
.disabled=${!this._dirty ||
!!this._submitting ||
!!(this._item && !this._item.name)}
>
@@ -134,36 +134,48 @@ export class EntitySettingsHelperTab extends LitElement {
`;
}
private _valueChanged(ev: ValueChangedEvent<Helper>): void {
private get _isHelperDirty(): boolean {
if (!this._item || !this._originalItemJson) return false;
return JSON.stringify(this._item) !== this._originalItemJson;
}
private _updateDirty() {
this._dirty = (this._registryEditor?.dirty ?? false) || this._isHelperDirty;
}
private _entityRegistryChanged() {
this._error = undefined;
this._updateDirty();
}
private _valueChanged(ev: CustomEvent): void {
if (this._item === null) {
return;
}
this._error = undefined;
this._item = ev.detail.value;
this._dirtyState?.setState(this._item, "helper");
this._updateDirty();
}
private async _getItem() {
const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!);
const item =
items.find((helper) => helper.id === this.entry.unique_id) || null;
this._item = item;
this._dirtyState?.setState(item, "helper");
this._item = items.find((item) => item.id === this.entry.unique_id) || null;
this._originalItemJson = this._item
? JSON.stringify(this._item)
: undefined;
}
private async _updateItem(): Promise<void> {
this._submitting = true;
this._error = undefined;
try {
if (this._componentLoaded && this._item) {
await HELPERS_CRUD[this.entry.platform].update(
this.hass,
this.hass!,
this._item.id,
this._item
);
}
const result = await this._registryEditor!.updateEntry();
this._dirtyState?.markClean();
if (result.close) {
fireEvent(this, "close-dialog");
}
@@ -6,8 +6,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { supportsFeature } from "../../../common/entity/supports-feature";
@@ -45,10 +45,6 @@ import {
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteConfigEntry } from "../../../data/config_entries";
import {
@@ -148,28 +144,6 @@ const SCANNER_SOURCE_TYPES = ["router", "bluetooth", "bluetooth_le"];
const ZONE_DOMAINS = ["zone"];
export interface EntitySettingsState {
name: string | null;
icon: string | null;
entityId: string;
areaId: string | null;
labels: string[];
deviceClass: string | undefined;
disabledBy: EntityRegistryEntry["disabled_by"];
hiddenBy: EntityRegistryEntry["hidden_by"];
unitOfMeasurement: string | null | undefined;
precision: number | null | undefined;
defaultCode: string | null | undefined;
calendarColor: string | null;
precipitationUnit: string | null | undefined;
pressureUnit: string | null | undefined;
temperatureUnit: string | null | undefined;
visibilityUnit: string | null | undefined;
windSpeedUnit: string | null | undefined;
switchAsDomain: string;
switchAsInvert: boolean;
}
@customElement("entity-registry-settings-editor")
export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -184,53 +158,41 @@ export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public helperConfigEntry?: ConfigEntry;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext<
EntitySettingsState,
"entity-registry"
>;
@state() private _name!: string;
@state() private _icon!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@state() private _entityId!: string;
@state() private _deviceClass?: EntitySettingsState["deviceClass"];
@state() private _deviceClass?: string;
@state() private _switchAsDomain: EntitySettingsState["switchAsDomain"] =
"switch";
@state() private _switchAsDomain = "switch";
@state() private _switchAsInvert: EntitySettingsState["switchAsInvert"] =
false;
@state() private _switchAsInvert = false;
@state() private _areaId?: string | null;
@state() private _labels?: string[] | null;
@state() private _disabledBy!: EntitySettingsState["disabledBy"];
@state() private _disabledBy!: EntityRegistryEntry["disabled_by"];
@state() private _hiddenBy!: EntitySettingsState["hiddenBy"];
@state() private _hiddenBy!: EntityRegistryEntry["hidden_by"];
@state() private _device?: DeviceRegistryEntry;
@state()
private _unit_of_measurement?: EntitySettingsState["unitOfMeasurement"];
@state() private _unit_of_measurement?: string | null;
@state() private _precision?: EntitySettingsState["precision"];
@state() private _precision?: number | null;
@state()
private _precipitation_unit?: EntitySettingsState["precipitationUnit"];
@state() private _precipitation_unit?: string | null;
@state() private _pressure_unit?: EntitySettingsState["pressureUnit"];
@state() private _pressure_unit?: string | null;
@state()
private _temperature_unit?: EntitySettingsState["temperatureUnit"];
@state() private _temperature_unit?: string | null;
@state() private _visibility_unit?: EntitySettingsState["visibilityUnit"];
@state() private _visibility_unit?: string | null;
@state() private _wind_speed_unit?: EntitySettingsState["windSpeedUnit"];
@state() private _wind_speed_unit?: string | null;
@state() private _cameraPrefs?: CameraPreferences;
@@ -242,9 +204,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _weatherConvertibleUnits?: WeatherUnits;
@state() private _defaultCode?: EntitySettingsState["defaultCode"];
@state() private _defaultCode?: string | null;
@state() private _calendarColor?: EntitySettingsState["calendarColor"];
@state() private _calendarColor?: string | null;
@state() private _associatedZone?: string;
@@ -254,6 +216,34 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _deviceClassOptions?: string[][];
private _initialStateJson!: string;
private _lastDirty = false;
private _currentState() {
return {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
disabledBy: this._disabledBy,
hiddenBy: this._hiddenBy,
unitOfMeasurement: this._unit_of_measurement,
precision: this._precision,
defaultCode: this._defaultCode,
calendarColor: this._calendarColor ?? null,
precipitationUnit: this._precipitation_unit,
pressureUnit: this._pressure_unit,
temperatureUnit: this._temperature_unit,
visibilityUnit: this._visibility_unit,
windSpeedUnit: this._wind_speed_unit,
switchAsDomain: this._switchAsDomain,
switchAsInvert: this._switchAsInvert,
};
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (
@@ -325,6 +315,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._wind_speed_unit = stateObj?.attributes?.wind_speed_unit;
}
this._initialStateJson = JSON.stringify(this._currentState());
this._lastDirty = false;
const deviceClasses: string[][] = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses || this._hideDeviceClassOverride(domain)) {
@@ -372,45 +365,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
protected async updated(changedProps: PropertyValues): Promise<void> {
if (changedProps.has("helperConfigEntry")) {
if (this.helperConfigEntry?.domain === "switch_as_x") {
this._switchAsDomain = computeDomain(this.entry.entity_id);
this.hass.loadBackendTranslation("title", SWITCH_AS_DOMAINS, false);
} else {
this._switchAsDomain = "switch";
this._switchAsInvert = false;
}
}
if (this._name === undefined || this._entityId === undefined) {
return;
}
this._dirtyState?.setState(
{
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
disabledBy: this._disabledBy,
hiddenBy: this._hiddenBy,
unitOfMeasurement: this._unit_of_measurement,
precision: this._precision,
defaultCode: this._defaultCode,
calendarColor: this._calendarColor ?? null,
precipitationUnit: this._precipitation_unit,
pressureUnit: this._pressure_unit,
temperatureUnit: this._temperature_unit,
visibilityUnit: this._visibility_unit,
windSpeedUnit: this._wind_speed_unit,
switchAsDomain: this._switchAsDomain,
switchAsInvert: this._switchAsInvert,
},
"entity-registry"
);
if (changedProps.has("_deviceClass")) {
const domain = computeDomain(this.entry.entity_id);
@@ -454,6 +408,25 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._weatherConvertibleUnits = undefined;
}
}
if (changedProps.has("helperConfigEntry")) {
if (this.helperConfigEntry?.domain === "switch_as_x") {
this._switchAsDomain = computeDomain(this.entry.entity_id);
this.hass.loadBackendTranslation("title", SWITCH_AS_DOMAINS, false);
} else {
this._switchAsDomain = "switch";
this._switchAsInvert = false;
}
this._initialStateJson = JSON.stringify(this._currentState());
this._lastDirty = false;
}
if (this._initialStateJson) {
const dirty = this.dirty;
if (dirty !== this._lastDirty) {
this._lastDirty = dirty;
fireEvent(this, "change");
}
}
}
protected render() {
@@ -1171,6 +1144,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
`;
}
public get dirty(): boolean {
return JSON.stringify(this._currentState()) !== this._initialStateJson;
}
public async updateEntry(): Promise<{
close: boolean;
entry: ExtEntityRegistryEntry;
@@ -1456,10 +1433,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _nameChanged(ev: InputEvent): void {
fireEvent(this, "change");
this._name = (ev.target as HTMLInputElement).value;
}
private _iconChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._icon = ev.detail.value;
}
@@ -1478,18 +1457,22 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _entityIdChanged(ev: InputEvent): void {
fireEvent(this, "change");
this._entityId = `${computeDomain(this._origEntityId)}.${(ev.target as HTMLInputElement).value}`;
}
private _deviceClassChanged(ev: HaSelectSelectEvent<string, true>): void {
fireEvent(this, "change");
this._deviceClass = ev.detail.value;
}
private _unitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._unit_of_measurement = ev.detail.value;
}
private _defaultcodeChanged(ev: InputEvent): void {
fireEvent(this, "change");
this._defaultCode =
(ev.target as HTMLInputElement).value === ""
? null
@@ -1497,35 +1480,43 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _calendarColorChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._calendarColor = ev.detail.value || null;
}
private _associatedZoneChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._associatedZone = ev.detail.value || "zone.home";
}
private _precipitationUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._precipitation_unit = ev.detail.value;
}
private _precisionChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._precision =
ev.detail.value === "default" ? null : Number(ev.detail.value);
}
private _pressureUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._pressure_unit = ev.detail.value;
}
private _temperatureUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._temperature_unit = ev.detail.value;
}
private _visibilityUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._visibility_unit = ev.detail.value;
}
private _windSpeedUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._wind_speed_unit = ev.detail.value;
}
@@ -1560,6 +1551,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _areaPicked(ev: CustomEvent) {
fireEvent(this, "change");
this._areaId = ev.detail.value;
}
@@ -1632,6 +1624,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
fireEvent(this, "change");
this._openDeviceSettings();
}
@@ -2,7 +2,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consume } from "@lit/context";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
@@ -14,10 +13,6 @@ import {
deleteConfigEntry,
getConfigEntry,
} from "../../../data/config_entries";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
@@ -43,16 +38,14 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@property({ type: Object }) public entry!: ExtEntityRegistryEntry;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
@state() private _helperConfigEntry?: ConfigEntry;
@state() private _error?: string;
@state() private _submitting?: boolean;
@state() private _dirty = false;
@query("entity-registry-settings-editor")
private _registryEditor?: EntityRegistrySettingsEditor;
@@ -140,6 +133,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
.entry=${this.entry}
.helperConfigEntry=${this._helperConfigEntry}
.disabled=${!!this._submitting}
@change=${this._entityRegistryChanged}
></entity-registry-settings-editor>
</div>
<div class="buttons">
@@ -154,7 +148,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
</ha-button>
<ha-button
@click=${this._updateEntry}
.disabled=${!this._dirtyState?.isDirty || !!this._submitting}
.disabled=${!this._dirty || !!this._submitting}
.loading=${!!this._submitting}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
@@ -163,6 +157,11 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
`;
}
private _entityRegistryChanged() {
this._error = undefined;
this._dirty = this._registryEditor?.dirty ?? false;
}
private _openDeviceSettings() {
const device = this.hass.devices[this.entry.device_id!];
@@ -208,10 +207,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
private async _updateEntry(): Promise<void> {
this._submitting = true;
this._error = undefined;
try {
const result = await this._registryEditor!.updateEntry();
this._dirtyState?.markClean();
if (result.close) {
fireEvent(this, "close-dialog");
}
+6 -10
View File
@@ -221,7 +221,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@state() private _helperEntities?: HassEntity[];
@state() private _helperEntities: HassEntity[] = [];
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
@@ -229,7 +229,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _configEntries?: Record<string, ConfigEntry>;
@state() private _entitySource?: Record<string, string>;
@state() private _entitySource: Record<string, string> = {};
@state() private _selected: string[] = [];
@@ -499,7 +499,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
configEntry !== undefined || entityState.attributes.editable,
type: configEntry
? configEntry.domain
: this._entitySource![entityState.entity_id] ||
: this._entitySource[entityState.entity_id] ||
computeStateDomain(entityState),
configEntry,
entity: entityState,
@@ -830,9 +830,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
}
private _applyFilters() {
if (!this._helperEntities) {
return;
}
const filters = Object.entries(this._filters);
let items: Set<string> | undefined;
@@ -1006,10 +1003,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
if (!entityReg) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.helpers.picker.no_category_support"
"ui.panel.config.automation.picker.no_category_support"
),
text: this.hass.localize(
"ui.panel.config.helpers.picker.no_category_entity_reg"
"ui.panel.config.automation.picker.no_category_entity_reg"
),
});
return;
@@ -1227,7 +1224,7 @@ ${rejected
this._setFiltersFromUrl();
}
if (!this._entityEntries || !this._configEntries || !this._entitySource) {
if (!this._entityEntries || !this._configEntries) {
return;
}
@@ -1268,7 +1265,6 @@ ${rejected
);
if (
!this._helperEntities ||
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
) {
@@ -151,7 +151,6 @@ class HaDomainIntegrations extends LitElement {
.map(
([dom, val]) =>
html`<ha-integration-list-item
.domain=${dom}
.integration=${{
...val,
domain: dom,
@@ -2,7 +2,6 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
@@ -11,7 +10,6 @@ import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button-arrow-prev";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import type { MatterCommissionFinish } from "../../../../../external_app/external_messaging";
import {
commissionMatterDevice,
watchForNewMatterDevice,
@@ -84,10 +82,6 @@ class DialogMatterAddDevice extends LitElement {
@state() private _mainEntity?: ExtEntityRegistryEntry;
@state() private _proposedDeviceName?: string;
@state() private _commissioningFinished = false;
@state() private _deviceAddedState: {
name: string;
area: string | undefined;
@@ -110,63 +104,15 @@ class DialogMatterAddDevice extends LitElement {
// make sure a refresh of the page will navigate to the device page, old iOS apps will refresh the webview when commissioning is done
setRefreshUrl(`/config/devices/device/${device.id}`);
this._newDevice = device;
this._step = "device_added";
this._fetchMainEntity();
this._maybeShowDeviceAdded();
});
if (this._waitForCommissioningFinish) {
window.addEventListener(
"matter-commission-finish",
this._handleCommissionFinish
);
}
}
public closeDialog(): void {
this._open = false;
}
private get _waitForCommissioningFinish(): boolean {
// When the app supports reporting Matter commissioning status, defer
// advancing past the spinner until we receive matter/commission/finish.
return !!this.hass.auth.external?.config.hasMatterStatusReport;
}
private _maybeShowDeviceAdded(): void {
if (!this._newDevice) {
return;
}
if (this._waitForCommissioningFinish && !this._commissioningFinished) {
return;
}
this._step = "device_added";
}
private _handleCommissionFinish = (
ev: HASSDomEvent<MatterCommissionFinish>
) => {
const { name, success } = ev.detail;
if (!success) {
if (this._newDevice) {
// Device already showed up in the registry — ignore the failure signal
// and let the user finish the rename flow.
return;
}
showToast(this, {
message: this.hass.localize(
"ui.dialogs.matter-add-device.add_device_failed"
),
duration: 2000,
});
this.closeDialog();
return;
}
this._commissioningFinished = true;
if (name) {
this._proposedDeviceName = name;
}
this._maybeShowDeviceAdded();
};
protected updated(changedProps: Map<string, unknown>): void {
// Retry fetching main entity when hass updates (entities may not be available immediately)
if (
@@ -214,8 +160,6 @@ class DialogMatterAddDevice extends LitElement {
this._newDevice = undefined;
this._mainEntity = undefined;
this._mainEntityFetched = false;
this._proposedDeviceName = undefined;
this._commissioningFinished = false;
this._deviceAddedState = {
name: "",
area: undefined,
@@ -224,10 +168,6 @@ class DialogMatterAddDevice extends LitElement {
};
this._unsub?.();
this._unsub = undefined;
window.removeEventListener(
"matter-commission-finish",
this._handleCommissionFinish
);
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -271,7 +211,6 @@ class DialogMatterAddDevice extends LitElement {
hass: this.hass,
device: this._newDevice,
mainEntity: this._mainEntity,
proposedName: this._proposedDeviceName,
}
)}
</div>
@@ -36,8 +36,6 @@ class MatterAddDeviceDeviceAdded extends LitElement {
@property({ attribute: false }) public mainEntity?: ExtEntityRegistryEntry;
@property({ attribute: false }) public proposedName?: string;
@state() private _deviceName = "";
@state() private _area: string | undefined;
@@ -51,18 +49,8 @@ class MatterAddDeviceDeviceAdded extends LitElement {
protected willUpdate(changedProps: PropertyValues) {
if (!this._initialized && this.device) {
this._initialized = true;
this._deviceName =
this.proposedName || (computeDeviceName(this.device) ?? "");
this._deviceName = computeDeviceName(this.device) ?? "";
this._area = this.device.area_id ?? undefined;
} else if (
changedProps.has("proposedName") &&
this.proposedName &&
this.device &&
this._deviceName === (computeDeviceName(this.device) ?? "")
) {
// proposedName arrived after we initialized, and the user hasn't
// changed the name yet — adopt it
this._deviceName = this.proposedName;
}
if (
!this._deviceClassInitialized &&
@@ -170,9 +158,7 @@ class MatterAddDeviceDeviceAdded extends LitElement {
referrerpolicy="no-referrer"
/>
<div class="device-name">
<span
>${this.proposedName || computeDeviceName(this.device)}</span
>
<span>${computeDeviceName(this.device)}</span>
<span class="secondary">Matter</span>
</div>
</div>
@@ -1,203 +0,0 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../../components/ha-card";
import "../../../../../../components/ha-spinner";
import type { ZHADevice, ZHAGroup } from "../../../../../../data/zha";
import { fetchBindableDevices, fetchGroups } from "../../../../../../data/zha";
import { haStyle } from "../../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../../types";
import "../zha-device-binding";
import { sortZHADevices, sortZHAGroups } from "../functions";
import "../zha-group-binding";
@customElement("zha-device-bindings-pane")
export class ZHADeviceBindingsPane extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device?: ZHADevice;
@state() private _bindableDevices: ZHADevice[] = [];
@state() private _groups: ZHAGroup[] = [];
@state() private _loaded = false;
@state() private _error?: string;
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged) {
this._bindableDevices = [];
this._groups = [];
this._loaded = false;
this._error = undefined;
this._fetchBindings();
}
}
protected render(): TemplateResult | typeof nothing {
if (!this.device) {
return nothing;
}
if (!this._loaded) {
return html`
<ha-card class="loading-card">
<ha-spinner size="large"></ha-spinner>
</ha-card>
`;
}
if (this._error) {
return html`<ha-card class="empty-card">${this._error}</ha-card>`;
}
if (!this._bindableDevices.length && !this._groups.length) {
return html`
<ha-card class="empty-card">
${this.hass.localize("ui.panel.config.zha.device_page.no_bindings")}
</ha-card>
`;
}
return html`
${this._bindableDevices.length
? html`
<ha-card class="binding-card">
<div class="binding-section-header">
<div class="binding-section-title">
${this.hass.localize(
"ui.panel.config.zha.device_binding.header"
)}
</div>
<div class="binding-section-description">
${this.hass.localize(
"ui.panel.config.zha.device_binding.introduction"
)}
</div>
</div>
<zha-device-binding-control
.hass=${this.hass}
.device=${this.device}
.bindableDevices=${this._bindableDevices}
></zha-device-binding-control>
</ha-card>
`
: nothing}
${this._groups.length
? html`
<ha-card class="binding-card">
<div class="binding-section-header">
<div class="binding-section-title">
${this.hass.localize(
"ui.panel.config.zha.group_binding.header"
)}
</div>
<div class="binding-section-description">
${this.hass.localize(
"ui.panel.config.zha.group_binding.introduction"
)}
</div>
</div>
<zha-group-binding-control
.hass=${this.hass}
.device=${this.device}
.groups=${this._groups}
></zha-group-binding-control>
</ha-card>
`
: nothing}
`;
}
private async _fetchBindings(): Promise<void> {
if (!this.device || !this.hass) {
return;
}
const ieee = this.device.ieee;
try {
const [bindableDevices, groups] = await Promise.all([
this.device.device_type !== "Coordinator"
? fetchBindableDevices(this.hass, ieee)
: Promise.resolve([]),
fetchGroups(this.hass),
]);
if (this.device?.ieee !== ieee) {
return;
}
this._bindableDevices = bindableDevices.sort(sortZHADevices);
this._groups = groups.sort(sortZHAGroups);
} catch (_err: any) {
if (this.device?.ieee === ieee) {
this._error = this.hass.localize(
"ui.panel.config.zha.device_page.bindings_error"
);
}
} finally {
if (this.device?.ieee === ieee) {
this._loaded = true;
}
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.binding-card {
overflow: hidden;
}
.binding-section-header {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
.binding-section-title {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
.binding-section-description {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
margin-top: var(--ha-space-1);
}
.loading-card,
.empty-card {
display: flex;
justify-content: center;
padding: var(--ha-space-8);
}
.empty-card {
color: var(--secondary-text-color);
text-align: center;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-device-bindings-pane": ZHADeviceBindingsPane;
}
}
@@ -1,27 +0,0 @@
import { css } from "lit";
export const zhaDevicePageCardStyles = css`
:host {
display: block;
}
.device-page-card {
overflow: hidden;
}
.card-header {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
.card-title {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
.card-description {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
margin-top: var(--ha-space-1);
}
`;
@@ -1,387 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiCogRefresh,
mdiDelete,
mdiDotsVertical,
mdiFamilyTree,
mdiPlus,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import checkValidDate from "../../../../../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../../../../../common/datetime/format_date_time";
import { navigate } from "../../../../../../common/navigate";
import "../../../../../../components/ha-button";
import "../../../../../../components/ha-card";
import "../../../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../../../components/ha-dropdown";
import "../../../../../../components/ha-dropdown-item";
import "../../../../../../components/ha-icon-button";
import "../../../../../../components/ha-relative-time";
import "../../../../../../components/ha-svg-icon";
import {
apiContext,
configContext,
internationalizationContext,
} from "../../../../../../data/context";
import type { ZHADevice } from "../../../../../../data/zha";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../../../resources/styles";
import { formatAsPaddedHex } from "../functions";
import { showZHAReconfigureDeviceDialog } from "../show-dialog-zha-reconfigure-device";
type ZHADeviceAction = "add-via" | "view-network" | "remove";
@customElement("zha-device-summary-card")
export class ZHADeviceSummaryCard extends LitElement {
@property({ attribute: false }) public device?: ZHADevice;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _processingRemove = false;
protected render(): TemplateResult | typeof nothing {
if (!this.device || !this._config || !this._i18n) {
return nothing;
}
const name = this.device.user_given_name || this.device.name;
return html`
<ha-card>
<div class="device-heading">
<div class="device-name">${name}</div>
${this.device.user_given_name
? html`<div class="device-subtitle">${this.device.name}</div>`
: nothing}
</div>
<div class="section-header">
${this._i18n.localize("ui.panel.config.zha.device_page.information")}
</div>
<div class="summary-grid">
${this._renderSummaryItem(
this._i18n.localize("ui.panel.config.zha.device_page.ieee"),
this.device.ieee
)}
${this._renderSummaryItem(
this._i18n.localize("ui.panel.config.zha.device_page.nwk"),
formatAsPaddedHex(this.device.nwk)
)}
${this._renderSummaryItem(
this._i18n.localize(
"ui.panel.config.zha.visualization.device_type"
),
this.device.device_type
)}
${this._renderSummaryItem(
this._i18n.localize("ui.dialogs.zha_device_info.power_source"),
this.device.power_source ||
this._i18n.localize("ui.dialogs.zha_device_info.unknown")
)}
${this._renderLastSeenSummaryItem()}
</div>
<div class="card-actions">
${!this.device.active_coordinator
? html`
<ha-button appearance="plain" @click=${this._reconfigureDevice}>
<ha-svg-icon
slot="start"
.path=${mdiCogRefresh}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.zha_device_info.buttons.reconfigure"
)}
</ha-button>
`
: html`
<ha-button appearance="plain" @click=${this._viewNetwork}>
<ha-svg-icon
slot="start"
.path=${mdiFamilyTree}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.zha_device_info.buttons.view_network"
)}
</ha-button>
`}
${this._renderDeviceActionMenu()}
</div>
</ha-card>
`;
}
private _renderDeviceActionMenu(): TemplateResult | typeof nothing {
const canAddViaDevice =
this.device!.power_source === "Mains" &&
(this.device!.device_type === "Router" ||
this.device!.device_type === "Coordinator");
const canManageDevice = !this.device!.active_coordinator;
if (!canAddViaDevice && !canManageDevice) {
return nothing;
}
return html`
<ha-dropdown
placement="bottom-end"
@wa-select=${this._handleDeviceActionSelected}
>
<ha-icon-button
slot="trigger"
.label=${this._i18n.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${canAddViaDevice
? html`
<ha-dropdown-item value="add-via">
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
${this._i18n.localize("ui.dialogs.zha_device_info.buttons.add")}
</ha-dropdown-item>
`
: nothing}
${canManageDevice
? html`
<ha-dropdown-item value="view-network">
<ha-svg-icon slot="icon" .path=${mdiFamilyTree}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.zha_device_info.buttons.view_network"
)}
</ha-dropdown-item>
<ha-dropdown-item
value="remove"
variant="danger"
.disabled=${this._processingRemove}
>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.zha_device_info.buttons.remove"
)}
</ha-dropdown-item>
`
: nothing}
</ha-dropdown>
`;
}
private _renderSummaryItem(
label: string,
value: string | number
): TemplateResult {
return html`
<div>
<span class="summary-label">${label}</span>
<span class="summary-value" title=${String(value)}>${value}</span>
</div>
`;
}
private _renderLastSeenSummaryItem(): TemplateResult {
const label = this._i18n.localize("ui.dialogs.zha_device_info.last_seen");
const lastSeen = this.device!.last_seen;
if (!lastSeen) {
return this._renderSummaryItem(
label,
this._i18n.localize("ui.dialogs.zha_device_info.unknown")
);
}
const date = new Date(lastSeen);
if (!checkValidDate(date)) {
return this._renderSummaryItem(label, lastSeen);
}
return html`
<div>
<span class="summary-label">${label}</span>
<span
class="summary-value"
title=${formatDateTimeWithSeconds(
date,
this._i18n.locale,
this._config.config
)}
>
<ha-relative-time .datetime=${lastSeen}></ha-relative-time>
</span>
</div>
`;
}
private _reconfigureDevice(): void {
showZHAReconfigureDeviceDialog(this, { device: this.device! });
}
private _addViaDevice(): void {
navigate(`/config/zha/add/${this.device!.ieee}`);
}
private _viewNetwork(): void {
navigate(`/config/zha/visualization/${this.device!.device_reg_id}`);
}
private _handleDeviceActionSelected(
ev: HaDropdownSelectEvent<ZHADeviceAction>
): void {
switch (ev.detail.item.value) {
case "add-via":
this._addViaDevice();
break;
case "view-network":
this._viewNetwork();
break;
case "remove":
this._removeDevice();
break;
}
}
private async _removeDevice(): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
title: this._i18n.localize(
"ui.dialogs.zha_device_info.confirmations.remove_title"
),
text: this._i18n.localize(
"ui.dialogs.zha_device_info.confirmations.remove_text"
),
confirmText: this._i18n.localize("ui.common.remove"),
dismissText: this._i18n.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
this._processingRemove = true;
try {
await this._api.callService("zha", "remove", {
ieee: this.device!.ieee,
});
navigate("/config/devices", { replace: true });
} finally {
this._processingRemove = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host,
ha-card {
display: block;
}
ha-card {
overflow: hidden;
}
.device-heading {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
.device-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
.device-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--secondary-text-color);
margin-top: var(--ha-space-1);
line-height: var(--ha-line-height-condensed);
}
.section-header {
padding: var(--ha-space-4) var(--ha-space-4) 0;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
.summary-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--ha-space-3);
padding: var(--ha-space-4);
}
.summary-label,
.summary-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-label {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-condensed);
}
.summary-value {
margin-top: var(--ha-space-1);
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-condensed);
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
padding: var(--ha-space-1) var(--ha-space-4) var(--ha-space-1)
var(--ha-space-1);
}
.card-actions ha-button {
min-width: 0;
}
.card-actions ha-dropdown {
flex: 0 0 auto;
}
@media (max-width: 800px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.summary-grid {
grid-template-columns: 1fr;
gap: var(--ha-space-2);
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-device-summary-card": ZHADeviceSummaryCard;
}
}
@@ -0,0 +1,252 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-dialog-header";
import "../../../../../components/ha-tab-group";
import "../../../../../components/ha-tab-group-tab";
import "../../../../../components/ha-dialog";
import type { ZHADevice, ZHAGroup } from "../../../../../data/zha";
import { fetchBindableDevices, fetchGroups } from "../../../../../data/zha";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { sortZHADevices, sortZHAGroups } from "./functions";
import type {
Tab,
ZHAManageZigbeeDeviceDialogParams,
} from "./show-dialog-zha-manage-zigbee-device";
import "./zha-cluster-attributes";
import "./zha-cluster-commands";
import "./zha-device-binding";
import "./zha-device-neighbors";
import "./zha-device-signature";
import "./zha-group-binding";
import "./zha-manage-clusters";
@customElement("dialog-zha-manage-zigbee-device")
class DialogZHAManageZigbeeDevice extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _currTab: Tab = "clusters";
@state() private _device?: ZHADevice;
@state() private _bindableDevices: ZHADevice[] = [];
@state() private _groups: ZHAGroup[] = [];
@state() private _open = false;
public async showDialog(
params: ZHAManageZigbeeDeviceDialogParams
): Promise<void> {
this._device = params.device;
if (!this._device) {
this.closeDialog();
return;
}
this._currTab = params.tab || "clusters";
this.large = false;
this._open = true;
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
this._device = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("close-dialog", () => this.closeDialog());
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this._device) {
return;
}
if (changedProps.has("_device")) {
const tabs = this._getTabs(this._device);
if (!tabs.includes(this._currTab)) {
this._currTab = tabs[0];
}
this._fetchData();
}
}
protected render() {
if (!this._device) {
return nothing;
}
const tabs = this._getTabs(this._device);
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
.title=${this.hass.localize("ui.dialogs.zha_manage_device.heading")}
@click=${this._enlarge}
>
${this.hass.localize("ui.dialogs.zha_manage_device.heading")}
</span>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.dialogs.zha_manage_device.tabs.${tab}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
</ha-dialog-header>
<div class="content" tabindex="-1" autofocus>
${cache(
this._currTab === "clusters"
? html`
<zha-manage-clusters
.hass=${this.hass}
.device=${this._device}
></zha-manage-clusters>
`
: this._currTab === "bindings"
? html`
${this._bindableDevices.length > 0
? html`
<zha-device-binding-control
.hass=${this.hass}
.device=${this._device}
.bindableDevices=${this._bindableDevices}
></zha-device-binding-control>
`
: nothing}
${this._device && this._groups.length > 0
? html`
<zha-group-binding-control
.hass=${this.hass}
.device=${this._device}
.groups=${this._groups}
></zha-group-binding-control>
`
: nothing}
`
: this._currTab === "signature"
? html`
<zha-device-zigbee-info
.hass=${this.hass}
.device=${this._device}
></zha-device-zigbee-info>
`
: html`
<zha-device-neighbors
.hass=${this.hass}
.device=${this._device}
.narrow=${!this.large}
></zha-device-neighbors>
`
)}
</div>
</ha-dialog>
`;
}
private async _fetchData(): Promise<void> {
if (this._device && this.hass) {
this._bindableDevices =
this._device && this._device.device_type !== "Coordinator"
? (await fetchBindableDevices(this.hass, this._device.ieee)).sort(
sortZHADevices
)
: [];
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
}
}
private _enlarge() {
this.large = !this.large;
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name as Tab;
if (newTab === this._currTab) {
return;
}
this._currTab = newTab;
}
private _getTabs = memoizeOne((device: ZHADevice | undefined) => {
const tabs: Tab[] = ["clusters", "bindings", "signature"];
if (
device &&
(device.device_type === "Router" || device.device_type === "Coordinator")
) {
tabs.push("neighbors");
}
return tabs;
});
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.content {
outline: none;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zha-manage-zigbee-device": DialogZHAManageZigbeeDevice;
}
}
@@ -1,4 +1,3 @@
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { Cluster, ZHADevice, ZHAGroup } from "../../../../../data/zha";
export const formatAsPaddedHex = (value: string | number): string => {
@@ -28,16 +27,3 @@ export const computeClusterKey = (cluster: Cluster): string =>
`${cluster.name} (Endpoint id: ${
cluster.endpoint_id
}, Id: ${formatAsPaddedHex(cluster.id)}, Type: ${cluster.type})`;
export const computeClusterSecondary = (
cluster: Cluster,
localize: LocalizeFunc
): string =>
localize("ui.panel.config.zha.clusters.cluster_description", {
endpoint: cluster.endpoint_id,
id: formatAsPaddedHex(cluster.id),
type: cluster.type,
});
export const computeClusterValue = (cluster: Cluster): string =>
`${cluster.endpoint_id}-${cluster.id}-${cluster.type}`;
@@ -0,0 +1,23 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { ZHADevice } from "../../../../../data/zha";
export type Tab = "clusters" | "bindings" | "signature" | "neighbors";
export interface ZHAManageZigbeeDeviceDialogParams {
device: ZHADevice;
tab?: Tab;
}
export const loadZHAManageZigbeeDeviceDialog = () =>
import("./dialog-zha-manage-zigbee-device");
export const showZHAManageZigbeeDeviceDialog = (
element: HTMLElement,
params: ZHAManageZigbeeDeviceDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zha-manage-zigbee-device",
dialogImport: loadZHAManageZigbeeDeviceDialog,
dialogParams: params,
});
};
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/buttons/ha-call-service-button";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
@@ -59,7 +60,7 @@ export class ZHAClusterAttributes extends LitElement {
return nothing;
}
return html`
<div class="content">
<ha-card class="content">
<div class="attribute-picker">
<ha-select
.label=${this.hass!.localize(
@@ -78,7 +79,7 @@ export class ZHAClusterAttributes extends LitElement {
${this._selectedAttributeId !== undefined
? this._renderAttributeInteractions()
: nothing}
</div>
</ha-card>
`;
}
@@ -221,12 +222,8 @@ export class ZHAClusterAttributes extends LitElement {
return [
haStyle,
css`
:host {
display: block;
}
.content {
padding-top: var(--ha-space-4);
ha-card {
border: none;
}
ha-select {
@@ -238,7 +235,12 @@ export class ZHAClusterAttributes extends LitElement {
width: 100%;
}
.card-actions.warning ha-call-service-button {
color: var(--error-color);
}
.attribute-picker {
align-items: center;
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
@@ -254,12 +256,15 @@ export class ZHAClusterAttributes extends LitElement {
padding-bottom: 10px;
}
.header {
flex-grow: 1;
}
.card-actions {
display: flex;
border-top: 1px solid var(--divider-color);
padding: var(--ha-space-2);
margin-top: var(--ha-space-2);
justify-content: flex-end;
gap: var(--ha-space-2);
gap: var(--ha-space-3);
}
`,
];
@@ -2,6 +2,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/buttons/ha-call-service-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-form/ha-form";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
@@ -53,7 +54,7 @@ export class ZHAClusterCommands extends LitElement {
return nothing;
}
return html`
<div class="content">
<ha-card class="content">
<div class="command-picker">
<ha-select
.label=${this.hass!.localize(
@@ -110,7 +111,7 @@ export class ZHAClusterCommands extends LitElement {
</div>
`
: ""}
</div>
</ha-card>
`;
}
@@ -183,12 +184,8 @@ export class ZHAClusterCommands extends LitElement {
return [
haStyle,
css`
:host {
display: block;
}
.content {
padding-top: var(--ha-space-4);
ha-card {
border: none;
}
ha-select {
@@ -199,7 +196,12 @@ export class ZHAClusterCommands extends LitElement {
width: 100%;
}
.card-actions.warning ha-call-service-button {
color: var(--error-color);
}
.command-picker {
align-items: center;
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
@@ -223,10 +225,24 @@ export class ZHAClusterCommands extends LitElement {
padding-bottom: 10px;
}
.header {
flex-grow: 1;
}
.toggle-help-icon {
float: right;
top: -6px;
right: 0;
inset-inline-end: 0;
inset-inline-start: initial;
padding-right: 0px;
padding-inline-end: 0px;
padding-inline-start: initial;
color: var(--primary-color);
}
.card-actions {
display: flex;
border-top: 1px solid var(--divider-color);
padding: var(--ha-space-2);
justify-content: flex-end;
}
`,
@@ -35,10 +35,6 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
tag: "zha-add-group-page",
load: () => import("./zha-add-group-page"),
},
device: {
tag: "zha-device-page",
load: () => import("./zha-device-page"),
},
visualization: {
tag: "zha-network-visualization-page",
load: () => import("./zha-network-visualization-page"),
@@ -66,7 +62,7 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
if (this._currentPage === "group") {
el.groupId = this.routeTail.path.substr(1);
} else if (this._currentPage === "device") {
el.ieee = this.routeTail.path.split("/")[1];
el.ieee = this.routeTail.path.substr(1);
} else if (this._currentPage === "visualization") {
el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1);
} else if (this._currentPage === "section") {
@@ -2,6 +2,7 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import type { ZHADevice } from "../../../../../data/zha";
@@ -15,29 +16,24 @@ export class ZHADeviceBindingControl extends LitElement {
@property({ attribute: false }) public device?: ZHADevice;
@property({ attribute: false }) public bindableDevices: ZHADevice[] = [];
@state() private _bindTargetIndex = -1;
@state() private bindableDevices: ZHADevice[] = [];
@state() private _deviceToBind?: ZHADevice;
@state() private _bindingOperationInProgress = false;
protected updated(changedProperties: PropertyValues<this>): void {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged || changedProperties.has("bindableDevices")) {
if (changedProperties.has("device")) {
this._bindTargetIndex = -1;
this._deviceToBind = undefined;
}
super.updated(changedProperties);
}
protected render(): TemplateResult {
return html`
<div class="content">
<ha-card class="content">
<div class="command-picker">
<ha-select
label=${this.hass!.localize(
@@ -73,7 +69,7 @@ export class ZHADeviceBindingControl extends LitElement {
${this.hass!.localize("ui.panel.config.zha.device_binding.bind")}
</ha-progress-button>
</div>
</div>
</ha-card>
`;
}
@@ -131,11 +127,14 @@ export class ZHADeviceBindingControl extends LitElement {
width: 100%;
}
:host {
display: block;
.content {
padding: var(--ha-space-4) 0 0;
border: none;
outline: none;
}
.command-picker {
align-items: center;
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
@@ -143,12 +142,14 @@ export class ZHADeviceBindingControl extends LitElement {
padding-bottom: 10px;
}
.header {
flex-grow: 1;
}
.card-actions {
display: flex;
border-top: 1px solid var(--divider-color);
padding: var(--ha-space-2);
margin-top: var(--ha-space-2);
justify-content: flex-end;
gap: var(--ha-space-2);
gap: var(--ha-space-3);
}
`,
];
@@ -1,6 +1,5 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../../../components/data-table/ha-data-table";
@@ -8,14 +7,11 @@ import type {
DataTableColumnContainer,
DataTableRowData,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-card";
import "../../../../../components/ha-spinner";
import { narrowViewportContext } from "../../../../../data/context";
import "../../../../../components/ha-code-editor";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices } from "../../../../../data/zha";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { zhaDevicePageCardStyles } from "./device-page/zha-device-page-card-styles";
export interface DeviceRowData extends DataTableRowData {
id: string;
@@ -29,25 +25,15 @@ export interface DeviceRowData extends DataTableRowData {
class ZHADeviceNeighbors extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device?: ZHADevice;
@property({ type: Boolean }) public narrow = false;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
private _narrow!: ContextType<typeof narrowViewportContext>;
@property({ attribute: false }) public device?: ZHADevice;
@state() private _devices: Map<string, ZHADevice> | undefined;
@state() private _loaded = false;
@state() private _error?: string;
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (this.hass && deviceChanged) {
if (this.hass && changedProperties.has("device")) {
this._fetchData();
}
}
@@ -61,14 +47,15 @@ class ZHADeviceNeighbors extends LitElement {
if (device && devices) {
device.neighbors.forEach((neighbor) => {
const zhaDevice: ZHADevice | undefined = devices.get(neighbor.ieee);
outputDevices.push({
name:
zhaDevice?.user_given_name || zhaDevice?.name || neighbor.ieee,
id: zhaDevice?.device_reg_id || neighbor.ieee,
lqi: Number(neighbor.lqi),
depth: Number(neighbor.depth),
relationship: neighbor.relationship,
});
if (zhaDevice) {
outputDevices.push({
name: zhaDevice.user_given_name || zhaDevice.name,
id: zhaDevice.device_reg_id,
lqi: parseInt(neighbor.lqi),
depth: parseInt(neighbor.depth),
relationship: neighbor.relationship,
});
}
});
}
return outputDevices;
@@ -123,128 +110,35 @@ class ZHADeviceNeighbors extends LitElement {
}
);
protected render(): TemplateResult | typeof nothing {
protected render() {
if (!this.device) {
return nothing;
}
if (!this._loaded) {
return html`
<ha-card class="loading-card">
<ha-spinner size="large"></ha-spinner>
</ha-card>
`;
}
if (this._error) {
return html`<ha-card class="empty-card">${this._error}</ha-card>`;
}
const neighbors = this._deviceNeighbors(this.device, this._devices);
if (!neighbors.length) {
return html`
<ha-card class="device-page-card">
${this._renderCardHeader()}
<div class="empty-content">
${this.hass.localize("ui.panel.config.zha.neighbors.no_neighbors")}
</div>
</ha-card>
`;
}
return html`
<ha-card class="device-page-card">
${this._renderCardHeader()}
<ha-data-table
.columns=${this._columns(this._narrow)}
.data=${neighbors}
auto-height
.searchLabel=${this.hass.localize("ui.components.data-table.search")}
.noDataText=${this.hass.localize(
"ui.panel.config.zha.neighbors.no_neighbors"
)}
></ha-data-table>
</ha-card>
`;
}
private _renderCardHeader(): TemplateResult {
return html`
<div class="card-header">
<div class="card-title">
${this.hass.localize(
"ui.panel.config.zha.device_page.tabs.neighbors"
)}
</div>
<div class="card-description">
${this.hass.localize(
"ui.panel.config.zha.device_page.tab_descriptions.neighbors"
)}
</div>
</div>
${!this._devices
? html`<ha-spinner size="large"></ha-spinner>`
: html`<ha-data-table
.columns=${this._columns(this.narrow)}
.data=${this._deviceNeighbors(this.device, this._devices)}
auto-height
.searchLabel=${this.hass.localize(
"ui.components.data-table.search"
)}
.noDataText=${this.hass.localize(
"ui.components.data-table.no-data"
)}
></ha-data-table>`}
`;
}
private async _fetchData(): Promise<void> {
if (this.device && this.hass) {
const ieee = this.device.ieee;
this._loaded = false;
this._error = undefined;
try {
const devices = await fetchDevices(this.hass);
if (this.device?.ieee !== ieee) {
return;
}
this._devices = new Map(
devices.map((device: ZHADevice) => [device.ieee, device])
);
} catch (_err: any) {
if (this.device?.ieee === ieee) {
this._error = this.hass.localize(
"ui.panel.config.zha.neighbors.load_failed"
);
this._devices = undefined;
}
} finally {
if (this.device?.ieee === ieee) {
this._loaded = true;
}
}
const devices = await fetchDevices(this.hass!);
this._devices = new Map(
devices.map((device: ZHADevice) => [device.ieee, device])
);
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
zhaDevicePageCardStyles,
css`
ha-data-table {
--data-table-background-color: var(--card-background-color);
--data-table-border-width: 0;
--ha-border-radius-sm: 0;
}
.loading-card,
.empty-card {
display: flex;
justify-content: center;
padding: var(--ha-space-8);
}
.empty-card {
color: var(--secondary-text-color);
text-align: center;
}
.empty-content {
color: var(--secondary-text-color);
padding: var(--ha-space-8);
text-align: center;
}
`,
];
}
}
declare global {
@@ -1,342 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiAccessPointNetwork,
mdiCodeJson,
mdiHexagonMultipleOutline,
mdiLinkVariant,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import memoizeOne from "memoize-one";
import { goBack, navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-spinner";
import { narrowViewportContext } from "../../../../../data/context";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchZHADevice } from "../../../../../data/zha";
import "../../../../../layouts/hass-error-screen";
import "../../../../../layouts/hass-subpage";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "./device-page/zha-device-bindings-pane";
import "./device-page/zha-device-summary-card";
import "./zha-device-neighbors";
import "./zha-device-signature";
import "./zha-manage-clusters";
type ZHADevicePageTab = "clusters" | "bindings" | "signature" | "neighbors";
const TAB_ICONS: Record<ZHADevicePageTab, string> = {
clusters: mdiHexagonMultipleOutline,
bindings: mdiLinkVariant,
signature: mdiCodeJson,
neighbors: mdiAccessPointNetwork,
};
const DEVICE_REFRESH_INTERVAL = 60000;
@customElement("zha-device-page")
class ZHADevicePage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ attribute: "ieee" }) public ieee!: string;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
private _narrow!: ContextType<typeof narrowViewportContext>;
@state() private _device?: ZHADevice;
@state() private _currTab: ZHADevicePageTab = "clusters";
@state() private _loading = false;
@state() private _error?: string;
private _deviceRefreshInterval?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._clearDeviceRefreshInterval();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("route")) {
this._syncTabFromRoute();
}
}
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("ieee")) {
this._clearDeviceRefreshInterval();
if (this.ieee) {
this._fetchDevice();
} else {
this._device = undefined;
this._loading = false;
this._error = this.hass.localize(
"ui.panel.config.zha.device_page.not_found"
);
}
}
}
protected render(): TemplateResult {
const header =
this._device?.user_given_name ||
this._device?.name ||
this.hass.localize("ui.panel.config.zha.device_page.heading");
if (this._loading || (!this._device && !this._error)) {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this._narrow}
.header=${header}
.backCallback=${this._goBack}
>
<div class="loading">
<ha-spinner size="large"></ha-spinner>
</div>
</hass-subpage>
`;
}
if (!this._device || this._error) {
return html`
<hass-error-screen
.hass=${this.hass}
.error=${this._error ||
this.hass.localize("ui.panel.config.zha.device_page.not_found")}
></hass-error-screen>
`;
}
const tabNavigation = this._getTabNavigation(this._device, this.ieee);
return html`
<hass-tabs-subpage
.hass=${this.hass}
.route=${this.route}
.tabs=${tabNavigation}
.backCallback=${this._goBack}
>
<div class="container">
<zha-device-summary-card
class="device-info"
.device=${this._device}
></zha-device-summary-card>
<div class="main-content">
${cache(
this._currTab === "clusters"
? html`
<zha-manage-clusters
.hass=${this.hass}
.device=${this._device}
></zha-manage-clusters>
`
: this._currTab === "bindings"
? html`
<zha-device-bindings-pane
.hass=${this.hass}
.device=${this._device}
></zha-device-bindings-pane>
`
: this._currTab === "signature"
? html`
<zha-device-zigbee-info
.hass=${this.hass}
.device=${this._device}
></zha-device-zigbee-info>
`
: html`
<zha-device-neighbors
.hass=${this.hass}
.device=${this._device}
></zha-device-neighbors>
`
)}
</div>
</div>
</hass-tabs-subpage>
`;
}
private async _fetchDevice(): Promise<void> {
const ieee = this.ieee;
this._loading = true;
this._error = undefined;
this._device = undefined;
try {
const device = await fetchZHADevice(this.hass, ieee);
if (this.ieee !== ieee) {
return;
}
this._device = device;
const tabs = this._getTabs(device);
if (!tabs.includes(this._currTab)) {
this._currTab = tabs[0];
}
this._startDeviceRefreshInterval();
} catch (_err: any) {
if (this.ieee === ieee) {
this._error = this.hass.localize(
"ui.panel.config.zha.device_page.not_found"
);
}
} finally {
if (this.ieee === ieee) {
this._loading = false;
}
}
}
private _startDeviceRefreshInterval(): void {
this._clearDeviceRefreshInterval();
this._deviceRefreshInterval = window.setInterval(
() => this._refreshDevice(),
DEVICE_REFRESH_INTERVAL
);
}
private _clearDeviceRefreshInterval(): void {
if (this._deviceRefreshInterval) {
window.clearInterval(this._deviceRefreshInterval);
this._deviceRefreshInterval = undefined;
}
}
private async _refreshDevice(): Promise<void> {
if (!this._device) {
return;
}
const ieee = this.ieee;
try {
const device = await fetchZHADevice(this.hass, ieee);
if (this.ieee === ieee) {
this._device = device;
}
} catch (_err: any) {
// Keep showing the current device details until a full page refresh fails.
}
}
private _syncTabFromRoute(): void {
const pathParts = this.route?.path.split("/").filter(Boolean) || [];
if (this.ieee && pathParts.length === 1) {
navigate(`/config/zha/device/${this.ieee}/clusters`, { replace: true });
return;
}
const newTab = (pathParts[1] as ZHADevicePageTab | undefined) || "clusters";
if (newTab === this._currTab) {
return;
}
this._currTab = this._isValidTab(newTab) ? newTab : "clusters";
}
private _isValidTab(tab: string): tab is ZHADevicePageTab {
return ["clusters", "bindings", "signature", "neighbors"].includes(tab);
}
private _goBack = (): void => {
goBack(
this._device
? `/config/devices/device/${this._device.device_reg_id}`
: "/config/zha/dashboard"
);
};
private _getTabs = memoizeOne((device: ZHADevice | undefined) => {
const tabs: ZHADevicePageTab[] = ["clusters", "bindings", "signature"];
if (
device &&
(device.device_type === "Router" || device.device_type === "Coordinator")
) {
tabs.push("neighbors");
}
return tabs;
});
private _getTabNavigation = memoizeOne(
(device: ZHADevice, ieee: string): PageNavigation[] =>
this._getTabs(device).map((tab) => ({
path: `/config/zha/device/${ieee}/${tab}`,
translationKey: `ui.panel.config.zha.device_page.tabs.${tab}`,
iconPath: TAB_ICONS[tab],
}))
);
static get styles(): CSSResultGroup {
return [
haStyle,
css`
hass-tabs-subpage {
--app-header-text-color: var(--sidebar-icon-color);
}
.container {
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: var(--ha-space-4);
align-items: start;
max-width: 1400px;
width: 100%;
margin: 0 auto;
padding: var(--ha-space-4) var(--ha-space-4)
calc(var(--ha-space-20) + var(--safe-area-inset-bottom, 0px));
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-12);
}
.device-info,
.main-content {
min-width: 0;
}
.main-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
@media (max-width: 1024px) {
.container {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
}
}
@media (max-width: 800px) {
.container {
grid-template-columns: 1fr;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-device-page": ZHADevicePage;
}
}
@@ -1,12 +1,9 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import type { ZHADevice } from "../../../../../data/zha";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { zhaDevicePageCardStyles } from "./device-page/zha-device-page-card-styles";
@customElement("zha-device-zigbee-info")
class ZHADeviceZigbeeInfo extends LitElement {
@@ -38,33 +35,10 @@ class ZHADeviceZigbeeInfo extends LitElement {
}
return html`
<ha-card class="device-page-card">
<div class="card-header">
<div class="card-title">
${this.hass.localize(
"ui.panel.config.zha.device_page.tabs.signature"
)}
</div>
<div class="card-description">
${this.hass.localize(
"ui.panel.config.zha.device_page.tab_descriptions.signature"
)}
</div>
</div>
<ha-code-editor
mode="yaml"
read-only
.value=${this._signature}
dir="ltr"
>
</ha-code-editor>
</ha-card>
<ha-code-editor mode="yaml" read-only .value=${this._signature} dir="ltr">
</ha-code-editor>
`;
}
static get styles(): CSSResultGroup {
return [haStyle, zhaDevicePageCardStyles];
}
}
declare global {
@@ -4,6 +4,7 @@ import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/buttons/ha-progress-button";
import type { SelectionChangedEvent } from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-card";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import type { Cluster, ZHADevice, ZHAGroup } from "../../../../../data/zha";
@@ -23,10 +24,10 @@ export class ZHAGroupBindingControl extends LitElement {
@property({ attribute: false }) public device?: ZHADevice;
@property({ attribute: false }) public groups: ZHAGroup[] = [];
@state() private _bindTargetIndex = -1;
@state() private groups: ZHAGroup[] = [];
@state() private _selectedClusters: string[] = [];
@state() private _clusters: Cluster[] = [];
@@ -41,17 +42,10 @@ export class ZHAGroupBindingControl extends LitElement {
private _zhaClustersDataTable!: ZHAClustersDataTable;
protected updated(changedProperties: PropertyValues<this>): void {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged || changedProperties.has("groups")) {
if (changedProperties.has("device")) {
this._bindTargetIndex = -1;
this._groupToBind = undefined;
this._selectedClusters = [];
this._clustersToBind = [];
}
if (deviceChanged) {
this._fetchClustersForZhaNode();
}
super.updated(changedProperties);
@@ -59,31 +53,31 @@ export class ZHAGroupBindingControl extends LitElement {
protected render(): TemplateResult {
return html`
<div class="content">
<div class="command-picker">
<ha-select
.label=${this.hass!.localize(
"ui.panel.config.zha.group_binding.group_picker_label"
)}
class="menu"
.value=${String(this._bindTargetIndex)}
@selected=${this._bindTargetIndexChanged}
.options=${this.groups.map((group, idx) => ({
value: String(idx),
label: group.name,
}))}
>
</ha-select>
</div>
<div class="command-picker">
<zha-clusters-data-table
.hass=${this.hass}
.clusters=${this._clusters}
@selection-changed=${this._handleClusterSelectionChanged}
class="menu"
></zha-clusters-data-table>
</div>
<div class="card-actions">
<ha-card class="content">
<div class="command-picker">
<ha-select
.label=${this.hass!.localize(
"ui.panel.config.zha.group_binding.group_picker_label"
)}
class="menu"
.value=${String(this._bindTargetIndex)}
@selected=${this._bindTargetIndexChanged}
.options=${this.groups.map((group, idx) => ({
value: String(idx),
label: group.name,
}))}
>
</ha-select>
</div>
<div class="command-picker">
<zha-clusters-data-table
.hass=${this.hass}
.clusters=${this._clusters}
@selection-changed=${this._handleClusterSelectionChanged}
class="menu"
></zha-clusters-data-table>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._onUnbindGroupClick}
.disabled=${!this._canBind || this._bindingOperationInProgress}
@@ -94,16 +88,17 @@ export class ZHAGroupBindingControl extends LitElement {
"ui.panel.config.zha.group_binding.unbind_button_label"
)}
</ha-progress-button>
<ha-progress-button
@click=${this._onBindGroupClick}
.disabled=${!this._canBind || this._bindingOperationInProgress}
>
${this.hass!.localize(
"ui.panel.config.zha.group_binding.bind_button_label"
)}
</ha-progress-button>
</div>
</div>
<ha-progress-button
@click=${this._onBindGroupClick}
.disabled=${!this._canBind || this._bindingOperationInProgress}
>
${this.hass!.localize(
"ui.panel.config.zha.group_binding.bind_button_label"
)}
</ha-progress-button>
</div>
</ha-card>
</ha-config-section>
`;
}
@@ -176,10 +171,10 @@ export class ZHAGroupBindingControl extends LitElement {
}
private async _fetchClustersForZhaNode(): Promise<void> {
if (this.hass && this.device) {
if (this.hass) {
this._clusters = await fetchClustersForZhaDevice(
this.hass,
this.device.ieee
this.device!.ieee
);
this._clusters = this._clusters
.filter((cluster) => cluster.type.toLowerCase() === "out")
@@ -204,11 +199,12 @@ export class ZHAGroupBindingControl extends LitElement {
width: 100%;
}
:host {
display: block;
.content {
padding-top: var(--ha-space-2);
}
.command-picker {
align-items: center;
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
@@ -216,12 +212,22 @@ export class ZHAGroupBindingControl extends LitElement {
padding-bottom: 10px;
}
.input-text {
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
padding-inline-end: 28px;
padding-bottom: 10px;
}
.sectionHeader {
flex-grow: 1;
}
.card-actions {
display: flex;
border-top: 1px solid var(--divider-color);
padding: var(--ha-space-2);
justify-content: flex-end;
gap: var(--ha-space-2);
gap: var(--ha-space-1);
}
`,
];
@@ -1,21 +1,17 @@
import { mdiChevronDown } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import memoizeOne from "memoize-one";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../../../components/ha-picker-combo-box";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-tab-group";
import "../../../../../components/ha-tab-group-tab";
import type { Cluster, ZHADevice } from "../../../../../data/zha";
import { fetchClustersForZhaDevice } from "../../../../../data/zha";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import { computeClusterSecondary, computeClusterValue } from "./functions";
import type { HomeAssistant } from "../../../../../types";
import { computeClusterKey } from "./functions";
import "./zha-cluster-attributes";
import "./zha-cluster-commands";
@@ -34,9 +30,11 @@ const tabs = ["attributes", "commands"] as const;
export class ZHAManageClusters extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public device?: ZHADevice;
@state() private _selectedClusterValue?: string;
@state() private _selectedClusterIndex = -1;
@state() private _clusters: Cluster[] = [];
@@ -46,10 +44,6 @@ export class ZHAManageClusters extends LitElement {
@state() private _clustersLoaded = false;
@state() private _clustersError?: string;
@query("ha-generic-picker") private _clusterPicker?: HaGenericPicker;
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.device) {
@@ -61,307 +55,138 @@ export class ZHAManageClusters extends LitElement {
}
protected updated(changedProperties: PropertyValues<this>): void {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged) {
if (changedProperties.has("device")) {
this._clusters = [];
this._selectedClusterValue = undefined;
this._selectedCluster = undefined;
this._selectedClusterIndex = -1;
this._clustersLoaded = false;
this._clustersError = undefined;
this._fetchClustersForZhaDevice();
}
super.updated(changedProperties);
}
protected render() {
if (!this.device) {
if (!this.device || !this._clustersLoaded) {
return nothing;
}
if (!this._clustersLoaded) {
return html`
<ha-card class="loading-card">
<ha-spinner size="large"></ha-spinner>
</ha-card>
`;
}
if (this._clustersError) {
return html`<ha-card class="empty-card">${this._clustersError}</ha-card>`;
}
if (!this._clusters.length) {
return html`
<ha-card class="empty-card">
${this.hass.localize("ui.panel.config.zha.clusters.no_clusters")}
</ha-card>
`;
}
return html`
<ha-card class="cluster-detail-card">
${this._renderClusterHeader()}
<ha-card class="content">
<div class="node-picker">
<ha-select
.label=${this.hass!.localize("ui.panel.config.zha.common.clusters")}
class="menu"
.value=${String(this._selectedClusterIndex)}
@selected=${this._selectedClusterChanged}
.options=${this._clusters.map((entry, idx) => ({
value: String(idx),
label: computeClusterKey(entry),
}))}
>
</ha-select>
</div>
${this._selectedCluster
? html`
${this._renderClusterSegmentedTabs()}
${this._renderSelectedClusterPanel()}
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>${this.hass.localize(
`ui.panel.config.zha.clusters.tabs.${tab}`
)}</ha-tab-group-tab
>
`
)}
</ha-tab-group>
<div class="content" tabindex="-1" dialogInitialFocus>
${cache(
this._currTab === "attributes"
? html`
<zha-cluster-attributes
.hass=${this.hass}
.device=${this.device}
.selectedCluster=${this._selectedCluster}
></zha-cluster-attributes>
`
: html`
<zha-cluster-commands
.hass=${this.hass}
.device=${this.device}
.selectedCluster=${this._selectedCluster}
></zha-cluster-commands>
`
)}
</div>
`
: nothing}
: ""}
</ha-card>
`;
}
private _renderClusterHeader(): TemplateResult {
return html`
<div class="cluster-header">
<div class="cluster-heading">
<div class="cluster-name">${this._selectedCluster?.name}</div>
<div class="cluster-description">
${this._selectedCluster
? computeClusterSecondary(
this._selectedCluster,
this.hass.localize
)
: nothing}
</div>
</div>
${this._renderClusterPicker()}
</div>
`;
}
private _renderClusterPicker(): TemplateResult {
return html`
<ha-generic-picker
no-sort
class="menu"
.label=${this.hass.localize("ui.panel.config.zha.clusters.header")}
.searchLabel=${this.hass.localize(
"ui.panel.config.zha.clusters.change_cluster"
)}
.getItems=${this._clusterItems(this._clusters, this.hass.localize)}
.value=${this._selectedClusterValue}
.notFoundLabel=${this.hass.localize(
"ui.panel.config.zha.clusters.no_clusters"
)}
@value-changed=${this._selectedClusterChanged}
hide-clear-icon
>
<ha-button
slot="field"
appearance="plain"
@click=${this._openClusterPicker}
>
${this.hass.localize("ui.panel.config.zha.clusters.change_cluster")}
<ha-svg-icon slot="end" .path=${mdiChevronDown}></ha-svg-icon>
</ha-button>
</ha-generic-picker>
`;
}
private _renderClusterSegmentedTabs(): TemplateResult {
return html`
<div class="cluster-tabs" role="tablist">
${tabs.map(
(tab) => html`
<button
role="tab"
type="button"
data-tab=${tab}
aria-selected=${this._currTab === tab}
class=${this._currTab === tab ? "active" : ""}
@click=${this._clusterTabClicked}
>
${this.hass.localize(`ui.panel.config.zha.clusters.tabs.${tab}`)}
</button>
`
)}
</div>
`;
}
private _renderSelectedClusterPanel(): TemplateResult {
return cache(
this._currTab === "attributes"
? html`
<zha-cluster-attributes
.hass=${this.hass}
.device=${this.device}
.selectedCluster=${this._selectedCluster}
></zha-cluster-attributes>
`
: html`
<zha-cluster-commands
.hass=${this.hass}
.device=${this.device}
.selectedCluster=${this._selectedCluster}
></zha-cluster-commands>
`
);
}
private async _fetchClustersForZhaDevice(): Promise<void> {
if (this.hass && this.device) {
const ieee = this.device.ieee;
try {
this._clusters = await fetchClustersForZhaDevice(this.hass, ieee);
if (this.device?.ieee !== ieee) {
return;
}
this._clusters.sort((a, b) => a.name.localeCompare(b.name));
if (this._clusters.length > 0) {
this._selectCluster(this._clusters[0]);
}
} catch (_err: any) {
if (this.device?.ieee === ieee) {
this._clustersError = this.hass.localize(
"ui.panel.config.zha.clusters.load_failed"
);
}
} finally {
if (this.device?.ieee === ieee) {
this._clustersLoaded = true;
}
if (this.hass) {
this._clusters = await fetchClustersForZhaDevice(
this.hass,
this.device!.ieee
);
this._clusters.sort((a, b) => a.name.localeCompare(b.name));
if (this._clusters.length > 0) {
this._selectedClusterIndex = 0;
this._selectedCluster = this._clusters[0];
}
this._clustersLoaded = true;
}
}
private _clusterTabClicked(event: Event): void {
this._selectClusterTab(
(event.currentTarget as HTMLElement).dataset.tab as (typeof tabs)[number]
);
}
private _selectClusterTab(newTab: (typeof tabs)[number]): void {
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
if (newTab === this._currTab) {
return;
}
this._currTab = newTab;
}
private _selectedClusterChanged(event: ValueChangedEvent<string>): void {
this._selectClusterValue(event.detail.value);
private _selectedClusterChanged(event: HaSelectSelectEvent): void {
this._selectedClusterIndex = Number(event.detail.value);
this._selectedCluster = this._clusters[this._selectedClusterIndex];
}
private _openClusterPicker(event: Event): void {
event.stopPropagation();
this._clusterPicker?.open();
}
private _selectClusterValue(value?: string): void {
this._selectCluster(
this._clusters.find((cluster) => computeClusterValue(cluster) === value)
);
}
private _selectCluster(cluster?: Cluster): void {
this._selectedCluster = cluster;
this._selectedClusterValue = cluster
? computeClusterValue(cluster)
: undefined;
}
private _clusterItems = memoizeOne(
(clusters: Cluster[], localize: HomeAssistant["localize"]) =>
(): PickerComboBoxItem[] =>
clusters.map((cluster) => ({
id: computeClusterValue(cluster),
primary: cluster.name,
secondary: computeClusterSecondary(cluster, localize),
sorting_label: cluster.name,
}))
);
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
.content {
padding: var(--ha-space-4) 0 0;
border: none;
outline: none;
}
.loading-card,
.empty-card {
display: flex;
justify-content: center;
padding: var(--ha-space-8);
ha-select {
margin-top: 16px;
}
.empty-card {
color: var(--secondary-text-color);
text-align: center;
}
.cluster-detail-card {
overflow: hidden;
}
.cluster-header {
align-items: center;
border-bottom: 1px solid var(--divider-color);
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
justify-content: space-between;
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
.cluster-heading {
min-width: 0;
}
.cluster-name {
color: var(--primary-text-color);
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cluster-description {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
margin-top: var(--ha-space-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cluster-header ha-button {
flex: none;
}
.menu {
width: auto;
width: 100%;
}
.header {
flex-grow: 1;
}
.node-picker {
align-items: center;
margin: 0 var(--ha-space-5);
padding-bottom: 10px;
}
.cluster-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
ha-tab-group {
margin: 0 var(--ha-space-5);
}
.cluster-tabs button {
appearance: none;
background: none;
border: 0;
border-bottom: 2px solid transparent;
color: var(--primary-text-color);
cursor: pointer;
font: inherit;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
min-width: 0;
padding: var(--ha-space-3) var(--ha-space-2);
ha-tab-group-tab {
flex: 1;
}
.cluster-tabs button.active {
border-bottom-color: var(--primary-color);
color: var(--primary-color);
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
+1 -53
View File
@@ -25,7 +25,6 @@ import { transform } from "../../../common/decorators/transform";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack, navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
@@ -48,11 +47,7 @@ import {
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
entityRegistryByEntityId,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { domainToName } from "../../../data/integration";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import type {
SceneConfig,
SceneEntities,
@@ -349,9 +344,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
this._deviceEntityLookup,
Object.values(this.hass.devices)
);
const entityRegistryLookup = entityRegistryByEntityId(
this._entityRegistryEntries
);
return html` <div
id="root"
class=${classMap({
@@ -431,19 +423,9 @@ export class HaSceneEditor extends PreventUnsavedMixin(
if (!entityStateObj) {
return nothing;
}
const { secondary } = computeEntityPickerDisplay(
this.hass,
entityStateObj
);
const platform =
entityRegistryLookup[entityId]?.platform;
const integrationName = platform
? domainToName(this.hass.localize, platform)
: undefined;
return html`
<ha-list-item
hasMeta
?twoline=${!!secondary}
.graphic=${this._mode === "live"
? "icon"
: undefined}
@@ -463,14 +445,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
`
: nothing}
${computeStateName(entityStateObj)}
${secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing}
${integrationName
? html`<span slot="meta" class="domain"
>${integrationName}</span
>`
: nothing}
</ha-list-item>
`;
})}
@@ -522,19 +496,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
if (!entityStateObj) {
return nothing;
}
const { secondary } = computeEntityPickerDisplay(
this.hass,
entityStateObj
);
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
return html`
<ha-list-item
class="entity"
hasMeta
?twoline=${!!secondary}
.graphic=${this._mode === "live"
? "icon"
: undefined}
@@ -552,13 +517,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
></state-badge>`
: nothing}
${computeStateName(entityStateObj)}
${secondary
? html`<span slot="secondary"
>${secondary}</span
>`
: nothing}
<div slot="meta">
<span class="domain">${domainName}</span>
<ha-icon-button
.path=${mdiDelete}
.entityId=${entityId}
@@ -1396,21 +1355,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
ha-list-item {
/* let the trailing label size to its content instead of the default
fixed meta width, which would clip it */
--mdc-list-item-meta-size: auto;
}
ha-list-item.entity {
padding-right: 28px;
}
.domain {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
white-space: nowrap;
}
`,
];
}
@@ -16,7 +16,6 @@ import type {
AssistPipelineMutableParams,
} from "../../../data/assist_pipeline";
import { fetchAssistPipelineLanguages } from "../../../data/assist_pipeline";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "./assist-pipeline-detail/assist-pipeline-detail-config";
@@ -29,9 +28,7 @@ import type { VoiceAssistantPipelineDetailsDialogParams } from "./show-dialog-vo
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("dialog-voice-assistant-pipeline-detail")
export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
Partial<AssistPipeline>
>()(LitElement) {
export class DialogVoiceAssistantPipelineDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: VoiceAssistantPipelineDetailsDialogParams;
@@ -65,7 +62,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
this._hideWakeWord =
this._params.hideWakeWord || !this._data.wake_word_entity;
this._initDirtyTracking({ type: "deep" }, this._data);
return;
}
@@ -102,7 +98,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
stt_engine: this._params.pipeline?.stt_engine || sstDefault,
tts_engine: this._params.pipeline?.tts_engine || ttsDefault,
};
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -150,7 +145,7 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${!this._hideWakeWord ||
@@ -271,7 +266,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
value[key] = ev.detail.value[key];
});
this._data = { ...this._data, ...value };
this._updateDirtyState(this._data);
}
private async _updatePipeline() {
@@ -8,7 +8,6 @@ import "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-button";
import type { HomeZoneMutableParams } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
@@ -22,9 +21,7 @@ const SCHEMA = [
];
@customElement("dialog-home-zone-detail")
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
LitElement
) {
class DialogHomeZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -46,7 +43,6 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
longitude: this.hass.config.longitude,
radius: this.hass.config.radius,
};
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -75,7 +71,7 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
header-title=${this.hass!.localize("ui.common.edit_item", {
name: this._data.name,
})}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-form
@@ -124,7 +120,6 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
value.radius = value.location.radius;
delete value.location;
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (): string => "";
+2 -7
View File
@@ -11,15 +11,12 @@ import "../../../components/ha-button";
import type { SchemaUnion } from "../../../components/ha-form/types";
import type { ZoneMutableParams } from "../../../data/zone";
import { getZoneEditorInitData } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
@customElement("dialog-zone-detail")
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
LitElement
) {
class DialogZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -56,7 +53,6 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
radius: 100,
};
}
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -97,7 +93,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
name: this._params.entry.name,
})
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-form
@@ -193,7 +189,6 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
delete value.icon;
}
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (
+68 -24
View File
@@ -13,16 +13,13 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import { computeDomain } from "../../common/entity/compute_domain";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
} from "../../common/url/history-logbook-query-params";
import {
createSearchParam,
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
@@ -233,18 +230,43 @@ class HaPanelHistory extends LitElement {
return;
}
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
const searchParams = extractSearchParamsObject();
const entityIds = searchParams.entity_id;
const deviceIds = searchParams.device_id;
const areaIds = searchParams.area_id;
const floorIds = searchParams.floor_id;
const labelsIds = searchParams.label_id;
if (entityIds || deviceIds || areaIds || floorIds || labelsIds) {
this._targetPickerValue = {};
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
if (entityIds) {
const splitIds = entityIds.split(",");
this._targetPickerValue!.entity_id = splitIds;
}
if (queryParams.end_date) {
this._endDate = queryParams.end_date;
if (deviceIds) {
const splitIds = deviceIds.split(",");
this._targetPickerValue!.device_id = splitIds;
}
if (areaIds) {
const splitIds = areaIds.split(",");
this._targetPickerValue!.area_id = splitIds;
}
if (floorIds) {
const splitIds = floorIds.split(",");
this._targetPickerValue!.floor_id = splitIds;
}
if (labelsIds) {
const splitIds = labelsIds.split(",");
this._targetPickerValue!.label_id = splitIds;
}
const startDate = searchParams.start_date;
if (startDate) {
this._startDate = new Date(startDate);
}
const endDate = searchParams.end_date;
if (endDate) {
this._endDate = new Date(endDate);
}
}
@@ -447,15 +469,37 @@ class HaPanelHistory extends LitElement {
}
private _updatePath() {
navigate(
createHistoryLogbookUrl(
"/history",
this._targetPickerValue,
this._startDate,
this._endDate
),
{ replace: true }
);
const params: Record<string, string> = {};
if (this._targetPickerValue.entity_id) {
params.entity_id = ensureArray(this._targetPickerValue.entity_id).join(
","
);
}
if (this._targetPickerValue.label_id) {
params.label_id = ensureArray(this._targetPickerValue.label_id).join(",");
}
if (this._targetPickerValue.floor_id) {
params.floor_id = ensureArray(this._targetPickerValue.floor_id).join(",");
}
if (this._targetPickerValue.area_id) {
params.area_id = ensureArray(this._targetPickerValue.area_id).join(",");
}
if (this._targetPickerValue.device_id) {
params.device_id = ensureArray(this._targetPickerValue.device_id).join(
","
);
}
if (this._startDate) {
params.start_date = this._startDate.toISOString();
}
if (this._endDate) {
params.end_date = this._endDate.toISOString();
}
navigate(`/history?${createSearchParam(params)}`, { replace: true });
}
private async _handleMenuAction(ev: HaDropdownSelectEvent) {
+71 -25
View File
@@ -4,15 +4,12 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import { goBack, navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
} from "../../common/url/history-logbook-query-params";
import {
createSearchParam,
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
@@ -188,17 +185,44 @@ export class HaPanelLogbook extends LitElement {
);
private _applyURLParams() {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
const searchParams = extractSearchParamsObject();
const entityIds = searchParams.entity_id;
const deviceIds = searchParams.device_id;
const areaIds = searchParams.area_id;
const floorIds = searchParams.floor_id;
const labelsIds = searchParams.label_id;
if (entityIds || deviceIds || areaIds || floorIds || labelsIds) {
this._targetPickerValue = {};
}
if (entityIds) {
const splitIds = entityIds.split(",");
this._targetPickerValue!.entity_id = splitIds;
}
if (deviceIds) {
const splitIds = deviceIds.split(",");
this._targetPickerValue!.device_id = splitIds;
}
if (areaIds) {
const splitIds = areaIds.split(",");
this._targetPickerValue!.area_id = splitIds;
}
if (floorIds) {
const splitIds = floorIds.split(",");
this._targetPickerValue!.floor_id = splitIds;
}
if (labelsIds) {
const splitIds = labelsIds.split(",");
this._targetPickerValue!.label_id = splitIds;
}
if (queryParams.start_date || queryParams.end_date) {
const startDate = queryParams.start_date ?? this._time.range[0];
const endDate = queryParams.end_date ?? this._time.range[1];
const startDateStr = searchParams.start_date;
const endDateStr = searchParams.end_date;
if (startDateStr || endDateStr) {
const startDate = startDateStr
? new Date(startDateStr)
: this._time.range[0];
const endDate = endDateStr ? new Date(endDateStr) : this._time.range[1];
// Only set if date has changed.
if (
@@ -207,8 +231,8 @@ export class HaPanelLogbook extends LitElement {
) {
this._time = {
range: [
queryParams.start_date ?? this._time.range[0],
queryParams.end_date ?? this._time.range[1],
startDateStr ? new Date(startDateStr) : this._time.range[0],
endDateStr ? new Date(endDateStr) : this._time.range[1],
],
};
}
@@ -230,15 +254,37 @@ export class HaPanelLogbook extends LitElement {
}
private _updatePath() {
navigate(
createHistoryLogbookUrl(
"/logbook",
this._targetPickerValue,
this._time.range[0],
this._time.range[1]
),
{ replace: true }
);
const params: Record<string, string> = {};
if (this._targetPickerValue.entity_id) {
params.entity_id = ensureArray(this._targetPickerValue.entity_id).join(
","
);
}
if (this._targetPickerValue.label_id) {
params.label_id = ensureArray(this._targetPickerValue.label_id).join(",");
}
if (this._targetPickerValue.floor_id) {
params.floor_id = ensureArray(this._targetPickerValue.floor_id).join(",");
}
if (this._targetPickerValue.area_id) {
params.area_id = ensureArray(this._targetPickerValue.area_id).join(",");
}
if (this._targetPickerValue.device_id) {
params.device_id = ensureArray(this._targetPickerValue.device_id).join(
","
);
}
if (this._time.range[0]) {
params.start_date = this._time.range[0].toISOString();
}
if (this._time.range[1]) {
params.end_date = this._time.range[1].toISOString();
}
navigate(`/logbook?${createSearchParam(params)}`, { replace: true });
}
private _refreshLogbook() {
@@ -11,10 +11,8 @@ import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
import { batteryLevelIcon } from "../../../common/entity/battery_icon";
import { batteryStateColorProperty } from "../../../common/entity/color/battery_color";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { computeCssVariable } from "../../../resources/css-variables";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
@@ -111,16 +109,6 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
`;
}
const attributes = this._computeAttributes(stateObj);
let batteryColorVar: string | undefined;
if (attributes.includes("battery")) {
const batteryLevel = stateObj.attributes.battery;
const batteryColorProperty = batteryStateColorProperty(batteryLevel);
if (batteryColorProperty) {
batteryColorVar = computeCssVariable(batteryColorProperty);
}
}
return html`
<ha-card
class=${stateObj.attributes.entity_picture ? "has-plant-image" : ""}
@@ -134,7 +122,7 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
</div>
</div>
<div class="content">
${attributes.map(
${this._computeAttributes(stateObj).map(
(item) => html`
<div
class="attributes"
@@ -146,7 +134,6 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
<div class="icon">
${item === "battery"
? html`<ha-icon
style="color: ${batteryColorVar};"
.icon=${batteryLevelIcon(stateObj.attributes.battery)}
></ha-icon>`
: html`<ha-svg-icon
@@ -247,11 +247,6 @@ export class HuiEntityEditor extends LitElement {
.entity ha-entity-picker {
flex-grow: 1;
}
ha-entity-picker:is([add-button]) {
display: block;
margin-inline-start: var(--ha-space-1);
margin-bottom: var(--ha-space-1);
}
ha-md-list {
gap: 8px;
padding-top: 0;
@@ -239,17 +239,9 @@ export class HaCardConditionEditor extends LitElement {
<ha-svg-icon
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
${hideLiveTest
? nothing
: html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`}
</div>
${!hideLiveTest && this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
? html`<ha-tooltip for="condition-live-test" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
@@ -257,6 +249,15 @@ export class HaCardConditionEditor extends LitElement {
${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
) || condition.condition}
${!hideLiveTest
? html`<ha-automation-row-live-test
id="condition-live-test"
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`
: nothing}
</h3>
<ha-automation-row-event-chip
.show=${this._testingResult !== undefined}
@@ -81,7 +81,6 @@ export class HuiEnergyGraphCardEditor
? [
{
name: "show_legend",
default: true,
required: false,
selector: { boolean: {} },
},
@@ -616,10 +616,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
margin-bottom: var(--ha-space-1);
color: var(--secondary-text-color);
}
ha-selector-select {
display: block;
margin-inline-start: var(--ha-space-1);
}
`,
];
}
-7
View File
@@ -1351,13 +1351,6 @@ class HUIRoot extends LitElement {
display: flex;
align-items: center;
}
.edit-mode .action-items ha-icon-button[disabled] {
--ha-color-on-disabled-quiet: color-mix(
in srgb,
var(--app-header-edit-text-color, #fff) 50%,
transparent
);
}
ha-tab-group {
--ha-tab-indicator-color: var(
--app-header-selection-bar-color,
@@ -1,6 +1,5 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
@@ -15,10 +14,6 @@ import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
type EntityRegistryDisplayEntry,
findBatteryChargingEntity,
} from "../../../data/entity/entity_registry";
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
@@ -38,16 +33,6 @@ export const maintenanceEntityFilters: EntityFilter[] = [
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntities = memoizeOne(
(
deviceId: string,
entities: HomeAssistant["entities"]
): EntityRegistryDisplayEntry[] => {
const entries = Object.values(entities);
return entries.filter((entity) => entity.device_id === deviceId);
}
);
export const filterLowBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
@@ -57,19 +42,6 @@ export const filterLowBatteryEntities = (
if (computeDomain(entityId) === "binary_sensor") {
return state === BINARY_STATE_ON;
}
const deviceId = hass.entities[entityId]?.device_id;
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
const batteryChargingEntity = findBatteryChargingEntity(hass, entities);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity?.entity_id]
: undefined;
if (batteryCharging && batteryCharging.state === "on") {
return false;
}
const stateValue = parseFloat(state);
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
});
+203 -40
View File
@@ -1,19 +1,34 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { normalizeLuminance } from "../../common/color/palette";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-settings-row";
import "../../components/ha-theme-settings";
import "../../components/ha-theme-picker";
import "../../components/input/ha-input";
import "../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../components/radio/ha-radio-group";
import "../../components/radio/ha-radio-option";
import {
saveThemePreferences,
subscribeThemePreferences,
} from "../../data/theme";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant, ThemeSettings } from "../../types";
import {
DefaultAccentColor,
DefaultPrimaryColor,
} from "../../resources/theme/color/color.globals";
import type {
HomeAssistant,
ThemeSettings,
ValueChangedEvent,
} from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { clearSelectedThemeState } from "../../util/ha-pref-storage";
const HOME_ASSISTANT_THEME = "default";
@customElement("ha-pick-theme-row")
export class HaPickThemeRow extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -39,6 +54,14 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
const hasThemes =
this.hass.themes.themes && Object.keys(this.hass.themes.themes).length;
const curThemeIsUseDefault = this.hass.selectedTheme?.theme === "";
const curTheme = this.hass.selectedTheme?.theme
? this.hass.selectedTheme?.theme
: this.hass.themes.darkMode
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const themeSettings = this.hass.selectedTheme;
const localTheme = this._getLocalTheme();
const showMigration =
this._userTheme !== undefined &&
@@ -46,15 +69,14 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
localTheme !== null;
return html`
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.hass.selectedTheme}
.narrow=${this.narrow}
.heading=${this.hass.localize("ui.panel.profile.themes.header")}
.description=${html`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading"
>${this.hass.localize("ui.panel.profile.themes.header")}</span
>
<span slot="description">
${!hasThemes
? this.hass.localize("ui.panel.profile.themes.error_no_theme")
: nothing}
: ""}
<a
href=${documentationUrl(
this.hass,
@@ -65,32 +87,81 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
>
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>
`}
.labels=${{
theme: this.hass.localize("ui.panel.profile.themes.dropdown_label"),
noTheme: this.hass.localize("ui.panel.profile.themes.use_default"),
mode: this.hass.localize("ui.panel.profile.themes.theme_mode"),
autoMode: this.hass.localize(
"ui.panel.profile.themes.dark_mode.auto"
),
lightMode: this.hass.localize(
"ui.panel.profile.themes.dark_mode.light"
),
darkMode: this.hass.localize(
"ui.panel.profile.themes.dark_mode.dark"
),
primaryColor: this.hass.localize(
"ui.panel.profile.themes.primary_color"
),
accentColor: this.hass.localize(
"ui.panel.profile.themes.accent_color"
),
reset: this.hass.localize("ui.panel.profile.themes.reset"),
}}
.themePickerDisabled=${!hasThemes}
include-default
@theme-settings-changed=${this._themeSettingsChanged}
></ha-theme-settings>
</span>
<ha-theme-picker
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.profile.themes.dropdown_label")}
.noThemeLabel=${this.hass.localize(
"ui.panel.profile.themes.use_default"
)}
.value=${this.hass.selectedTheme?.theme || undefined}
.disabled=${!hasThemes}
include-default
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
</ha-settings-row>
${curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
<ha-radio-group
@change=${this._handleDarkMode}
name="dark_mode"
.ariaLabel=${this.hass.localize(
"ui.panel.profile.themes.theme_mode"
)}
.value=${themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.hass.localize("ui.panel.profile.themes.dark_mode.auto")}
</ha-radio-option>
<ha-radio-option value="light">
${this.hass.localize("ui.panel.profile.themes.dark_mode.light")}
</ha-radio-option>
<ha-radio-option value="dark">
${this.hass.localize("ui.panel.profile.themes.dark_mode.dark")}
</ha-radio-option>
</ha-radio-group>
${curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.hass.localize(
"ui.panel.profile.themes.primary_color"
)}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.hass.localize(
"ui.panel.profile.themes.accent_color"
)}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
${themeSettings?.primaryColor || themeSettings?.accentColor
? html` <ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.hass.localize("ui.panel.profile.themes.reset")}
</ha-button>`
: ""}
</div>`
: ""}
</div>`
: ""}
${showMigration
? html`
<ha-settings-row .narrow=${this.narrow}>
@@ -112,12 +183,77 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
</ha-button>
</ha-settings-row>
`
: nothing}
: ""}
`;
}
private _themeSettingsChanged(ev: HASSDomEvent<Partial<ThemeSettings>>) {
fireEvent(this, "settheme", ev.detail);
private _handleColorChange(ev: CustomEvent) {
const target = ev.target as any;
// normalize primary color if needed for contrast
if (target.name === "primaryColor") {
target.value = normalizeLuminance(target.value);
}
fireEvent(this, "settheme", { [target.name]: target.value });
}
private _resetColors() {
fireEvent(this, "settheme", {
primaryColor: undefined,
accentColor: undefined,
});
}
private _supportsModeSelection(themeName: string): boolean {
const theme = this.hass.themes.themes[themeName];
if (!theme) {
return false; // User's theme no longer exists
}
return !!(theme.modes && "light" in theme.modes && "dark" in theme.modes);
}
private _handleDarkMode(ev: Event) {
let dark: boolean | undefined;
switch ((ev.currentTarget as HaRadioGroup).value) {
case "light":
dark = false;
break;
case "dark":
dark = true;
break;
}
fireEvent(this, "settheme", { dark });
}
private _handleThemeSelection(
ev: ValueChangedEvent<string | undefined>
): void {
ev.stopPropagation();
const theme = ev.detail.value;
if (theme === undefined) {
// undefined = "use default"
if (this.hass.selectedTheme?.theme) {
fireEvent(this, "settheme", {
theme: "",
primaryColor: undefined,
accentColor: undefined,
});
}
return;
}
if (theme === this.hass.selectedTheme?.theme) {
return;
}
fireEvent(this, "settheme", {
theme,
primaryColor: undefined,
accentColor: undefined,
});
}
private _getLocalTheme(): ThemeSettings | null {
@@ -149,6 +285,33 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 12px;
}
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
}
.color-pickers {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 4px;
}
ha-theme-picker {
display: block;
width: 100%;
}
`;
}
+12 -32
View File
@@ -1197,7 +1197,6 @@
"data-table": {
"search": "Search",
"no-data": "No data",
"no_match_filter": "No rows matching current filters",
"filtering_by": "Filtering by",
"hidden": "{number} hidden",
"clear": "Clear",
@@ -2255,6 +2254,15 @@
"attribute": "Attribute",
"min_max_change": "min/max/change"
},
"zha_manage_device": {
"heading": "Manage Zigbee device",
"tabs": {
"clusters": "Clusters",
"bindings": "Bindings",
"signature": "Signature",
"neighbors": "Neighbors"
}
},
"zha_device_info": {
"manuf": "by {manufacturer}",
"no_area": "No area",
@@ -4381,9 +4389,7 @@
"error_information": "Error information",
"delete_confirm_title": "Delete helper?",
"delete_confirm_text": "Are you sure you want to delete {name}?",
"delete_failed": "Failed to delete helper",
"no_category_support": "You can't assign a category to this helper",
"no_category_entity_reg": "To assign a category to a helper it needs to have a unique ID."
"delete_failed": "Failed to delete helper"
},
"dialog": {
"create": "Create",
@@ -7328,32 +7334,10 @@
"no_devices_found": "No devices were found, make sure they are in pairing mode and keep them awake while Home Assistant is searching.",
"search_again": "Search again"
},
"device_page": {
"heading": "Manage Zigbee device",
"information": "Device information",
"ieee": "IEEE",
"nwk": "NWK",
"bindings_error": "Failed to load binding options",
"no_bindings": "No bindable devices or groups were found",
"not_found": "Zigbee device not found",
"tabs": {
"clusters": "Clusters",
"bindings": "Bindings",
"signature": "Signature",
"neighbors": "Neighbors"
},
"tab_descriptions": {
"signature": "View the raw Zigbee signature for this device.",
"neighbors": "View neighboring Zigbee devices reported by this device."
}
},
"add_device": "Add device",
"clusters": {
"header": "Clusters",
"change_cluster": "Change cluster",
"cluster_description": "Endpoint ID: {endpoint} · ID: {id} · Type: {type}",
"load_failed": "Failed to load clusters",
"no_clusters": "No clusters found",
"help_cluster_dropdown": "Select a cluster to view attributes and commands.",
"tabs": {
"attributes": "Attributes",
"commands": "Commands"
@@ -7430,8 +7414,6 @@
"offline": "Offline"
},
"device_binding": {
"header": "Device binding",
"introduction": "Bind this device to another Zigbee device or unbind it.",
"bind": "Bind",
"unbind": "Unbind",
"picker_label": "Bindable devices"
@@ -7451,9 +7433,7 @@
"name": "Name",
"lqi": "LQI",
"relationship": "Relationship",
"depth": "Depth",
"load_failed": "Failed to load neighbors",
"no_neighbors": "No neighbors found"
"depth": "Depth"
},
"change_channel_dialog": {
"title": "Change Zigbee channel",
+23
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
canShowPage,
isLoadedIntegration,
isNotLoadedIntegration,
isCore,
} from "../../../src/common/config/can_show_page";
import type { PageNavigation } from "../../../src/layouts/hass-tabs-subpage";
@@ -49,6 +50,28 @@ describe("isLoadedIntegration", () => {
});
});
describe("isNotLoadedIntegration", () => {
it("should return true if the integration is not loaded", () => {
const hass = {
config: { components: ["test_component"] },
} as unknown as HomeAssistant;
const page = {
not_component: "other_component",
} as unknown as PageNavigation;
expect(isNotLoadedIntegration(hass, page)).toBe(true);
});
it("should return false if the integration is loaded", () => {
const hass = {
config: { components: ["test_component"] },
} as unknown as HomeAssistant;
const page = {
not_component: "test_component",
} as unknown as PageNavigation;
expect(isNotLoadedIntegration(hass, page)).toBe(false);
});
});
describe("isCore", () => {
it("should return true if the page is core", () => {
const page = { core: true } as unknown as PageNavigation;
-146
View File
@@ -1,146 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookQueryParamConfig,
historyLogbookTargetParamKeys,
historyLogbookTargetFromQueryParams,
} from "../../../src/common/url/history-logbook-query-params";
import {
createQueryString,
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
} from "../../../src/common/url/query-params";
const panelQueryParams = [
{
type: "history",
path: "/history",
},
{
type: "logbook",
path: "/logbook",
},
] as const;
describe.each(panelQueryParams)("$type query params", (panel) => {
it("decodes target and date params", () => {
const params = decodeQueryParams(
"?entity_id=light.kitchen,switch.fan&device_id=device-1&area_id=kitchen&floor_id=downstairs&label_id=important&start_date=2026-06-05T10:00:00.000Z&end_date=2026-06-05T11:00:00.000Z&back=1",
historyLogbookQueryParamConfig
);
expect(params).toEqual({
entity_id: ["light.kitchen", "switch.fan"],
label_id: ["important"],
floor_id: ["downstairs"],
area_id: ["kitchen"],
device_id: ["device-1"],
start_date: new Date("2026-06-05T10:00:00.000Z"),
end_date: new Date("2026-06-05T11:00:00.000Z"),
back: true,
});
});
it("creates target picker values only when target params are present", () => {
expect(
serviceTargetFromQueryParams(
decodeQueryParams(
"?start_date=2026-06-05T10:00:00.000Z",
historyLogbookQueryParamConfig
),
historyLogbookTargetParamKeys
)
).toBeUndefined();
expect(
serviceTargetFromQueryParams(
decodeQueryParams(
"?entity_id=light.kitchen&area_id=kitchen",
historyLogbookQueryParamConfig
),
historyLogbookTargetParamKeys
)
).toEqual({
entity_id: ["light.kitchen"],
area_id: ["kitchen"],
});
});
it("ignores empty target values", () => {
expect(
serviceTargetFromQueryParams(
decodeQueryParams(
"?entity_id=&device_id=",
historyLogbookQueryParamConfig
),
historyLogbookTargetParamKeys
)
).toBeUndefined();
});
it("encodes target picker values", () => {
expect(
queryParamsFromServiceTarget(
{
entity_id: ["light.kitchen", "switch.fan"],
area_id: "kitchen",
},
historyLogbookTargetParamKeys
)
).toEqual({
entity_id: ["light.kitchen", "switch.fan"],
area_id: ["kitchen"],
});
});
it("creates deterministic query strings", () => {
expect(
createQueryString(
{
device_id: ["device-1"],
entity_id: ["light.kitchen"],
start_date: new Date("2026-06-05T10:00:00.000Z"),
end_date: new Date("2026-06-05T11:00:00.000Z"),
},
historyLogbookQueryParamConfig
)
).toBe(
"entity_id=light.kitchen&device_id=device-1&start_date=2026-06-05T10%3A00%3A00.000Z&end_date=2026-06-05T11%3A00%3A00.000Z"
);
});
it("creates typed URLs", () => {
expect(
createHistoryLogbookUrl(
panel.path,
{ entity_id: ["light.kitchen"] },
new Date("2026-06-05T10:00:00.000Z"),
new Date("2026-06-05T11:00:00.000Z")
)
).toBe(
`${panel.path}?entity_id=light.kitchen&start_date=2026-06-05T10%3A00%3A00.000Z&end_date=2026-06-05T11%3A00%3A00.000Z`
);
});
});
describe("history logbook query params", () => {
it("decodes query params", () => {
expect(
decodeHistoryLogbookQueryParams("?entity_id=light.kitchen&back=1")
).toEqual({
entity_id: ["light.kitchen"],
back: true,
});
});
it("creates target picker values only when target params are present", () => {
expect(
historyLogbookTargetFromQueryParams(
decodeHistoryLogbookQueryParams("?start_date=2026-06-05T10:00:00.000Z")
)
).toBeUndefined();
});
});
+209 -209
View File
@@ -1238,15 +1238,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/autocomplete@npm:6.20.3, @codemirror/autocomplete@npm:^6.0.0":
version: 6.20.3
resolution: "@codemirror/autocomplete@npm:6.20.3"
"@codemirror/autocomplete@npm:6.20.2, @codemirror/autocomplete@npm:^6.0.0":
version: 6.20.2
resolution: "@codemirror/autocomplete@npm:6.20.2"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
checksum: 10/1e66faedba3c7c1520b938838de2a95d4d4f16d168158e8dfd4d34bb72fe9538f4d9e73449bd2c8bba6f1f0a40f4a754e656637296d434216529c6426828aaf5
checksum: 10/ee0612e55a52b55262d897972741f1c54c92317b34735943c73cf1e8608fd3b21604b0f2fca0cbe67b9bddb3b673777023a45237086c1296f8f5103e0ad01219
languageName: node
linkType: hard
@@ -1646,135 +1646,135 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/bigdecimal@npm:0.2.6":
version: 0.2.6
resolution: "@formatjs/bigdecimal@npm:0.2.6"
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
"@formatjs/bigdecimal@npm:0.2.5":
version: 0.2.5
resolution: "@formatjs/bigdecimal@npm:0.2.5"
checksum: 10/035a70be4175d47d82d81025ad4386d7c248ef7afb2676b4e0773595d01df1078f3d5224e2f7f17721c9169bbb77d4282898624bf0112d2e6350438b7032a1cb
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/fast-memoize@npm:3.1.6"
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
"@formatjs/fast-memoize@npm:3.1.5":
version: 3.1.5
resolution: "@formatjs/fast-memoize@npm:3.1.5"
checksum: 10/5ff47e3cc5b46a72028cf0c6a3a291781cc3c7e198b6d4446119d870d6366ecc5a05d5826fce3568bab62abf9f7b4ebbe9bfb8420bb81bbe2a8a120b062ac909
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.11":
version: 3.5.11
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.11"
"@formatjs/icu-messageformat-parser@npm:3.5.10":
version: 3.5.10
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.10"
dependencies:
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
checksum: 10/59b8323b9615edb4ca6463d09108581469de7566f2aec106f1d87b259333cdbf9577d9c22a990bc454f3f461c799f4add683a9ff7185af6fa225fcd29ec336da
"@formatjs/icu-skeleton-parser": "npm:2.1.9"
checksum: 10/44392248b9247cf83a21b43c749025bfbc23acd63782b9a1b7dc47bf5520b686f8a5dccfa56716bc81fe0680000029aba22f5eb5c821ec529646758bd2d6af79
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.10":
version: 2.1.10
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
"@formatjs/icu-skeleton-parser@npm:2.1.9":
version: 2.1.9
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.9"
checksum: 10/eacb8acd60d487092fc1a6b7fdbac87dfc32475db7001562034a8ca7b0a4be7a35f95c30928ae8314f5e680f63302180bece9462c872a042a0302a5f4cf6a842
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.4.8":
version: 7.4.8
resolution: "@formatjs/intl-datetimeformat@npm:7.4.8"
"@formatjs/intl-datetimeformat@npm:7.4.7":
version: 7.4.7
resolution: "@formatjs/intl-datetimeformat@npm:7.4.7"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/9dde6796f1e260fbb486f27b1a5774a70aef2b4259b102b745b495d93ea5881f0df80d133bf92138cb003c77b7a016f125562f20360a92125680cc7f54621971
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/ab3a1806aede409f2eef59abd9657c0281de5b999dc992111fdc0c3d3e7fe3934e951abb934f469514111c262200985963e24a8d6b7bce1945f6d36dbd64f06f
languageName: node
linkType: hard
"@formatjs/intl-displaynames@npm:7.3.10":
version: 7.3.10
resolution: "@formatjs/intl-displaynames@npm:7.3.10"
"@formatjs/intl-displaynames@npm:7.3.9":
version: 7.3.9
resolution: "@formatjs/intl-displaynames@npm:7.3.9"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/da8809106f59e42c80d71f64d6965cb0c57daf5309976dedf2516ef1bd57912d46a5076f67f396406056425f42d415d10c6ddf9ed760971b484ac799462eb147
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/c5c6295be1e89b1ae497aab1df166714580499b67eda86e6504e54b29d434482633ae050c57d6fc06fd7d33b2efc5bf0405ff90933c328ec1f8924f1e349db55
languageName: node
linkType: hard
"@formatjs/intl-durationformat@npm:0.10.14":
version: 0.10.14
resolution: "@formatjs/intl-durationformat@npm:0.10.14"
"@formatjs/intl-durationformat@npm:0.10.13":
version: 0.10.13
resolution: "@formatjs/intl-durationformat@npm:0.10.13"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/0ca4898860322330442e150cde351924401f95aa772de276662c97e0c3e1dff619cac2054b608ce171d2235a4f215639deebd480eb63f81d747c70e9cd191c01
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/ae33f714dccd8a0be95b216e07fabfaf44de6dfb1a4de51a266a0f55e6fa510ce77a8da461ed106549ba5cb7f0bf35e3dc019b5e03f57388d9d5b1b8867e2b6f
languageName: node
linkType: hard
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
version: 3.2.10
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
"@formatjs/intl-getcanonicallocales@npm:3.2.9":
version: 3.2.9
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.9"
checksum: 10/818cf303c5fbc5607af6efc57720eabde1ea16eca4f58dde1f3f6f5ec21e7697d300e59dc61769c928ff9fc53b26ea510e5255e1d263f92f48e3baf43c8f984a
languageName: node
linkType: hard
"@formatjs/intl-listformat@npm:8.3.10":
version: 8.3.10
resolution: "@formatjs/intl-listformat@npm:8.3.10"
"@formatjs/intl-listformat@npm:8.3.9":
version: 8.3.9
resolution: "@formatjs/intl-listformat@npm:8.3.9"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/ebfc78061842ff45cfadab9e1676944093e421b04f21da16925b2ed04a71cdf8213cfc442f01e537d0de1255e745d03b74b935525d90c0df9f979627b1a70d97
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/36bee2f177d3d705ba9b95133b35404263fe08c15f781978d12b2a349d6e570b4ffdfae63a7a9ef233dfe8067e26584cadf6f4e252cd482c1b3a045dcca6bc52
languageName: node
linkType: hard
"@formatjs/intl-locale@npm:5.3.9":
version: 5.3.9
resolution: "@formatjs/intl-locale@npm:5.3.9"
"@formatjs/intl-locale@npm:5.3.8":
version: 5.3.8
resolution: "@formatjs/intl-locale@npm:5.3.8"
dependencies:
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
"@formatjs/intl-getcanonicallocales": "npm:3.2.9"
"@formatjs/intl-supportedvaluesof": "npm:2.3.7"
checksum: 10/86881fe5142b21f976e92c90ed34ac591ef4f470334f2d3f17f8a4784d64ba51d5a2a35f45e40da02a02227d2cc63dae2f20a0365299b0a6e44c5613f8777a92
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.10":
version: 0.8.10
resolution: "@formatjs/intl-localematcher@npm:0.8.10"
"@formatjs/intl-localematcher@npm:0.8.9":
version: 0.8.9
resolution: "@formatjs/intl-localematcher@npm:0.8.9"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/d9d3f408363091bf35950a842f58c662d88d9f54d9a53b1238cb673a73d1345412d78e01a2d56ec58cef81b9c66a8ce0ee78ed9c31976bd56dc68a38ef3cbc0f
"@formatjs/fast-memoize": "npm:3.1.5"
checksum: 10/05873efaae86a72b738dc1ce6296d975a74f27f90cd5dd4a27e704acd2389e58f5db84f4735e6b1043723cc65ab5636fb345ec88d1cf9a1dafad57180e4d06b0
languageName: node
linkType: hard
"@formatjs/intl-numberformat@npm:9.3.11":
version: 9.3.11
resolution: "@formatjs/intl-numberformat@npm:9.3.11"
"@formatjs/intl-numberformat@npm:9.3.10":
version: 9.3.10
resolution: "@formatjs/intl-numberformat@npm:9.3.10"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/ef8221c37c0611911b06c9a94d02e9d334048c0f2c8e5f049804f067afb628677e72215a9729a742f6b701b12d95d443b2e58a33981af6b65417b26f69d0d038
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/ef954ca4b60e46d98ee41514352a8977c8d9cf2ca1df684178f3571ecafa7d4c2a0e7270e45c9ccf54b2f1f43e4560a907fb06d9a70a7920fab5f239fe3a6205
languageName: node
linkType: hard
"@formatjs/intl-pluralrules@npm:6.3.10":
version: 6.3.10
resolution: "@formatjs/intl-pluralrules@npm:6.3.10"
"@formatjs/intl-pluralrules@npm:6.3.9":
version: 6.3.9
resolution: "@formatjs/intl-pluralrules@npm:6.3.9"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/88ade192dca87d3efa3367e68efb703495a8fbcca9bd30b0aea62286134407dfffada0adb5125618c157eacd83575ffaf890db478fabd82c3f67f1ea0b5472ab
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/c35cc1594596d3df557a683c89d5be18ee02150d2f9d2ba946e457e4ad2067e7f4d482487da1855f8f36ff83a773cef3f8aeee8c80ae7660d8c6eb6fe28f5985
languageName: node
linkType: hard
"@formatjs/intl-relativetimeformat@npm:12.3.10":
version: 12.3.10
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.10"
"@formatjs/intl-relativetimeformat@npm:12.3.9":
version: 12.3.9
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.9"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/f94f642d112ddaf0dd922c1887bf53d174bafed2aeb10d6305933cb9cb5fc7929e894516aa2d795fd4f5bfe5074330198e4dc3652a413d3e8bf3a272abcff7ea
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/cbeaa2d801f09ca6aad66aa15e1827d93997410029fbd1f7e55ae0a1cf56cde1973c8ba724afc0da349e2d30a670b1ec5f207d89f7aa502628d4c3f90a0a5b3c
languageName: node
linkType: hard
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
version: 2.3.8
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
"@formatjs/intl-supportedvaluesof@npm:2.3.7":
version: 2.3.7
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.7"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
"@formatjs/fast-memoize": "npm:3.1.5"
checksum: 10/89695ea2c2af7b0abbe5e2fe9b1c96047e61bfcceef57749b66693cf01bff66943fbefbdbaec5ec1015f003d2af500a81bae531d2f9a60d423e883532133be01
languageName: node
linkType: hard
@@ -4041,161 +4041,161 @@ __metadata:
languageName: node
linkType: hard
"@tsparticles/basic@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/basic@npm:4.1.3"
"@tsparticles/basic@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/basic@npm:4.1.2"
dependencies:
"@tsparticles/engine": "npm:4.1.3"
"@tsparticles/plugin-blend": "npm:4.1.3"
"@tsparticles/plugin-hex-color": "npm:4.1.3"
"@tsparticles/plugin-hsl-color": "npm:4.1.3"
"@tsparticles/plugin-move": "npm:4.1.3"
"@tsparticles/plugin-rgb-color": "npm:4.1.3"
"@tsparticles/shape-circle": "npm:4.1.3"
"@tsparticles/updater-opacity": "npm:4.1.3"
"@tsparticles/updater-out-modes": "npm:4.1.3"
"@tsparticles/updater-paint": "npm:4.1.3"
"@tsparticles/updater-size": "npm:4.1.3"
checksum: 10/daf230e24bb7557aa33b630958d7c858a14c8b446e56bf610a36d5365682fe4f635455f6c03717ce4cf6985057290155bfea0d6571bb69523968e63b79658062
"@tsparticles/engine": "npm:4.1.2"
"@tsparticles/plugin-blend": "npm:4.1.2"
"@tsparticles/plugin-hex-color": "npm:4.1.2"
"@tsparticles/plugin-hsl-color": "npm:4.1.2"
"@tsparticles/plugin-move": "npm:4.1.2"
"@tsparticles/plugin-rgb-color": "npm:4.1.2"
"@tsparticles/shape-circle": "npm:4.1.2"
"@tsparticles/updater-opacity": "npm:4.1.2"
"@tsparticles/updater-out-modes": "npm:4.1.2"
"@tsparticles/updater-paint": "npm:4.1.2"
"@tsparticles/updater-size": "npm:4.1.2"
checksum: 10/1c145d25373562cd3b45f20664610226c050a0a6867396c2d138a76761d3e7a5796cf107d8bdcbb8eb8cca19a3a1e4192eb356d37387d6547bf6b1ff796b71b2
languageName: node
linkType: hard
"@tsparticles/canvas-utils@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/canvas-utils@npm:4.1.3"
"@tsparticles/canvas-utils@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/canvas-utils@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/e69c6f9f49a32f4aa8d1e4444e93390615dddf95d62c2b14bfe53a113d417e5ffaf0cc0a8336456a6f1a6c559459454d6829d629e4ef28df9643c9ec12b9dbd0
"@tsparticles/engine": 4.1.2
checksum: 10/ffedc8400b5ff758331bdf1ef2362aac713c3e41d5bcece00153acc04c725947c41d545202d73bd6ef290d2ec220353f1801fb4ad22e99870989b0c68d7540c4
languageName: node
linkType: hard
"@tsparticles/engine@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/engine@npm:4.1.3"
checksum: 10/6506f055b044deb13fd910ec19fa9ebba7ac3cab8cd6d67ec5137e0a05ed88ff6a40514c62056c2f6c86882062ad42a9af440a24dede08fff6a3b0f270b1eb3c
"@tsparticles/engine@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/engine@npm:4.1.2"
checksum: 10/6fe6aa50bba564a8a8691945cb378267e695ec58323066a4157e8a036e7a3caa99e1ced08d05bffd173045bbc33c4d41523afc28624b1bea015393c1a88ef2bb
languageName: node
linkType: hard
"@tsparticles/interaction-particles-links@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/interaction-particles-links@npm:4.1.3"
"@tsparticles/interaction-particles-links@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/interaction-particles-links@npm:4.1.2"
dependencies:
"@tsparticles/canvas-utils": "npm:4.1.3"
"@tsparticles/canvas-utils": "npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
"@tsparticles/plugin-interactivity": 4.1.3
checksum: 10/770b77ef1f2e6caee5e3c364a40a3a184d93f2989f2ade7b83db8a043d2ffeb1ac5328c7fad8a9c00cc362bc467ccac215282c22c0fd9cd30005ad12393cabab
"@tsparticles/engine": 4.1.2
"@tsparticles/plugin-interactivity": 4.1.2
checksum: 10/b9e1e85bdb3226a25d7a4486556abfe458c81c109855c04857623d2d3506ef325fcc2dc4fcee962414e216aa6346bc0849df1bf7199a3e4e75bed2fb7f069702
languageName: node
linkType: hard
"@tsparticles/plugin-blend@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-blend@npm:4.1.3"
"@tsparticles/plugin-blend@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-blend@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/418a37e49dccf8c680953715be358ffe381a5198a7b683fe445d1052468fac9e4cc5b4026d43b1e9b9c43f341b27a7c1ce84d7e8fe24348580b9c07aa645951f
"@tsparticles/engine": 4.1.2
checksum: 10/1f51bcf76d6d749a0a18799fd3a814a82e62f060fd3c20d28f8bb170311815aeba8efc361e73cef7356c51151c7debafc75d34bff6ab2d4e58099b51774a28c6
languageName: node
linkType: hard
"@tsparticles/plugin-hex-color@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-hex-color@npm:4.1.3"
"@tsparticles/plugin-hex-color@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-hex-color@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/782cef17a059ffeba447c0f797d332b24bf91088c783f952f7d3d4f1c79c4ff95c35be404db952c9e03a091a3520a603638f20e9313d64014d41d41d7d2ab0fe
"@tsparticles/engine": 4.1.2
checksum: 10/d32a39bbd6732b9e630f84a7d226753735f02d65b70a1d5d11fa680421d5f1378aee75af1ecb5ada5af9875822bbaf2b5d1f213d1e0cbce2a50820743eb9c2fc
languageName: node
linkType: hard
"@tsparticles/plugin-hsl-color@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-hsl-color@npm:4.1.3"
"@tsparticles/plugin-hsl-color@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-hsl-color@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/0c53b7143288dd4e5f39525bac96c2bff70b0da32b49fc11be569077bc8c87b204bf541a9e4cbb007d0e697f00957fe8ca974ee39eddead355455f1e8719fe56
"@tsparticles/engine": 4.1.2
checksum: 10/c130e80b0fd2750402fa0e309d0bb624b2138474069ae4ccb76610722c4f0966f278ad1a35c85fbcf5449914936f1c53322246f1cdd028106d83f779beee538c
languageName: node
linkType: hard
"@tsparticles/plugin-interactivity@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-interactivity@npm:4.1.3"
"@tsparticles/plugin-interactivity@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-interactivity@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/286a1348d474a58f60151922e78e34dd7a3507b7c97f86490e09e80bc762539f02e7ee8c693536aafbd83beaf8a3b6708bab05941e1f82ab840f2d9325f77e47
"@tsparticles/engine": 4.1.2
checksum: 10/78efd4ffe3a07752f26262fe27ee0a931b45fea6c81d62e156d8f965dc22a2141d64395eb99477624ca9aac9e152f9e2585fc8aced9c822c61cb22ecfad184a4
languageName: node
linkType: hard
"@tsparticles/plugin-move@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-move@npm:4.1.3"
"@tsparticles/plugin-move@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-move@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/fe7ccb2d060ca80858676b2baea7f1a75f7ef7b794cae7e73044467c9d931ceed712aa78ebf768ac57c4a34b9997f4b508bf4b03e152fce209b6feba458b3aca
"@tsparticles/engine": 4.1.2
checksum: 10/fac5b90904d3fa62b310a1ce19647c5ae4a592111b5eeccfc6e946c6d17d677b0f3931ba3c114f04045827a92c1a0ed66791e99b352cb75d6c50f3749f389bfc
languageName: node
linkType: hard
"@tsparticles/plugin-rgb-color@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-rgb-color@npm:4.1.3"
"@tsparticles/plugin-rgb-color@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-rgb-color@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/ee32e1ce0f8670090c1dc4a1f89ecdc13a0d6a78c8deedfe925405ebebdecc386cc92cea71110259e3adc5d12f3b181e2125abdfefef791f86cf2f8bc8df0836
"@tsparticles/engine": 4.1.2
checksum: 10/7e3e83171f74f7e9e3f68ce8d0aeb4685b277ecac3c4874a951e86228d3db78978d6197ef9e04b17190471294323c561c18c92c9d9ae8e8a115e6238cbecdb43
languageName: node
linkType: hard
"@tsparticles/preset-links@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/preset-links@npm:4.1.3"
"@tsparticles/preset-links@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/preset-links@npm:4.1.2"
dependencies:
"@tsparticles/basic": "npm:4.1.3"
"@tsparticles/engine": "npm:4.1.3"
"@tsparticles/interaction-particles-links": "npm:4.1.3"
"@tsparticles/plugin-interactivity": "npm:4.1.3"
checksum: 10/7f9121f2c459c9ecacb2793332ae6bd7d8819eb74ab4de44258f28925a5e22f30052ed5167d3006270c1adc955de9b84bfc505b20d54dd3422e5e7500feb81a5
"@tsparticles/basic": "npm:4.1.2"
"@tsparticles/engine": "npm:4.1.2"
"@tsparticles/interaction-particles-links": "npm:4.1.2"
"@tsparticles/plugin-interactivity": "npm:4.1.2"
checksum: 10/d907884c4e9fd023aa8e0e1ff2388ef24d06a446433cc4ffbd620a4d22801f52ce97fe5f441b9fdea854f36d6f421d7571648925e5ea5c0976c121194156a2b1
languageName: node
linkType: hard
"@tsparticles/shape-circle@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/shape-circle@npm:4.1.3"
"@tsparticles/shape-circle@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/shape-circle@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/da8800a93096774d915989fc55499c0fc6775257a4d3b3141edb7d5cd5bf7354f4183d067041d094eba9e6ed1cfda79f1dd7ea51a06b3383490f1ed3b0513b61
"@tsparticles/engine": 4.1.2
checksum: 10/60a87755d4e598c278c1850b58b07bcfbef603de242de3979702cd8120cf24fd95d985b2092c7a8a2cccd43eb6a1939ab215f74a93898ea1e2d425be5deb405c
languageName: node
linkType: hard
"@tsparticles/updater-opacity@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-opacity@npm:4.1.3"
"@tsparticles/updater-opacity@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-opacity@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/298ad4ef6fd9043d15e3ab5704d405e81cc3ed95638e480991b8eb44fae8dc459c96e80c730b436d035f78dcc16d66a07554c25752982e7b231dedfd8801bdd2
"@tsparticles/engine": 4.1.2
checksum: 10/4dc426175bb3e4c5d3bc35e7d83ace7130c4f8817c7f88ee6bf1b4b1a52b28511d99b1fc7ded3c549a5baae401f7c22cc701d83919694387154aef86ea6974c2
languageName: node
linkType: hard
"@tsparticles/updater-out-modes@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-out-modes@npm:4.1.3"
"@tsparticles/updater-out-modes@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-out-modes@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/14a9aa5262dadcd2cc557936e24e64b5e4df57f96d3b327f8d195ed509b8d94f6fc07e3f9d7f3add67d665df9a9ecb9642704dc97455fd5c51475efdbdca9b71
"@tsparticles/engine": 4.1.2
checksum: 10/901be7b7dfa97d3d4ff857715a6b61dd4bc2ca09f23fee38d86cd6827351134c5cd4c3fa702d2aec8601544b1f6935f2b4034c4567a00128f9cf36cbaf379722
languageName: node
linkType: hard
"@tsparticles/updater-paint@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-paint@npm:4.1.3"
"@tsparticles/updater-paint@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-paint@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/8e29683068e57deedbea1d902b582e4b0457b284e62dbcf77c85330ab52b13bb08fef09f0c6aa3e77533f22e763bb7b4369069985436a6421569a3cdd62b3f57
"@tsparticles/engine": 4.1.2
checksum: 10/5a0b4bc0a4c6061e1eb93a6af7a97b472e5d552677dafe8c888b0f9a71b68ed10ef255d86f2cf8d8466ecd1d33ba8f5e52920947237fe1218cbcd002c50dff47
languageName: node
linkType: hard
"@tsparticles/updater-size@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-size@npm:4.1.3"
"@tsparticles/updater-size@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-size@npm:4.1.2"
peerDependencies:
"@tsparticles/engine": 4.1.3
checksum: 10/ec4376facea1ca6ab79adf76237975ce7762d89ed58f31ed52f0061f23c5e12e04a60786898e65fcfa323b33f21d0278d6918ff3cf225fb9817f76c9f8b93e79
"@tsparticles/engine": 4.1.2
checksum: 10/d5c4f79d5c1d3d977dd5ebac56c9679df134ddba919b8e2ee1473820f2c551f5b162356b49b22e6e54134b87a27c51cb5a6c2e2441ba2569b0dab62974e91a47
languageName: node
linkType: hard
@@ -7957,10 +7957,10 @@ __metadata:
languageName: node
linkType: hard
"fuse.js@npm:7.4.2":
version: 7.4.2
resolution: "fuse.js@npm:7.4.2"
checksum: 10/1605bb929331056f9215a8f0a19b2d22615d9195e17794f41254bdeeb77b0145ed0ad6b6842227b329783a4ab53586d795924e7a4a3fb664295f365ef6e2405c
"fuse.js@npm:7.4.1":
version: 7.4.1
resolution: "fuse.js@npm:7.4.1"
checksum: 10/581941d5015968ee624feb10a56d9b49d5d954672b2c9ec189d4ca513da6f8a3dea2d5f6637386d8298ffc5846f6d83435210d40a47c58e14d11dc5707544c75
languageName: node
linkType: hard
@@ -8437,7 +8437,7 @@ __metadata:
"@babel/runtime": "npm:7.29.7"
"@braintree/sanitize-url": "npm:7.1.2"
"@bundle-stats/plugin-webpack-filter": "npm:4.22.2"
"@codemirror/autocomplete": "npm:6.20.3"
"@codemirror/autocomplete": "npm:6.20.2"
"@codemirror/commands": "npm:6.10.3"
"@codemirror/lang-jinja": "npm:6.0.1"
"@codemirror/lang-yaml": "npm:6.1.3"
@@ -8449,15 +8449,15 @@ __metadata:
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
"@formatjs/intl-datetimeformat": "npm:7.4.8"
"@formatjs/intl-displaynames": "npm:7.3.10"
"@formatjs/intl-durationformat": "npm:0.10.14"
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-listformat": "npm:8.3.10"
"@formatjs/intl-locale": "npm:5.3.9"
"@formatjs/intl-numberformat": "npm:9.3.11"
"@formatjs/intl-pluralrules": "npm:6.3.10"
"@formatjs/intl-relativetimeformat": "npm:12.3.10"
"@formatjs/intl-datetimeformat": "npm:7.4.7"
"@formatjs/intl-displaynames": "npm:7.3.9"
"@formatjs/intl-durationformat": "npm:0.10.13"
"@formatjs/intl-getcanonicallocales": "npm:3.2.9"
"@formatjs/intl-listformat": "npm:8.3.9"
"@formatjs/intl-locale": "npm:5.3.8"
"@formatjs/intl-numberformat": "npm:9.3.10"
"@formatjs/intl-pluralrules": "npm:6.3.9"
"@formatjs/intl-relativetimeformat": "npm:12.3.9"
"@fullcalendar/core": "npm:6.1.20"
"@fullcalendar/daygrid": "npm:6.1.20"
"@fullcalendar/interaction": "npm:6.1.20"
@@ -8487,8 +8487,8 @@ __metadata:
"@rspack/dev-server": "npm:2.0.3"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.1.3"
"@tsparticles/preset-links": "npm:4.1.3"
"@tsparticles/engine": "npm:4.1.2"
"@tsparticles/preset-links": "npm:4.1.2"
"@types/chromecast-caf-receiver": "npm:6.0.26"
"@types/chromecast-caf-sender": "npm:1.0.11"
"@types/color-name": "npm:2.0.0"
@@ -8534,7 +8534,7 @@ __metadata:
eslint-plugin-wc: "npm:3.1.0"
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.3.5"
fuse.js: "npm:7.4.2"
fuse.js: "npm:7.4.1"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.6.0"
@@ -8549,7 +8549,7 @@ __metadata:
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.2.5"
intl-messageformat: "npm:11.2.8"
intl-messageformat: "npm:11.2.7"
js-yaml: "npm:4.2.0"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
@@ -8565,7 +8565,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:18.0.5"
marked: "npm:18.0.4"
memoize-one: "npm:6.0.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
@@ -8576,7 +8576,7 @@ __metadata:
qrcode: "npm:1.5.4"
roboto-fontface: "npm:0.10.0"
rrule: "npm:2.8.1"
rspack-manifest-plugin: "npm:5.2.2"
rspack-manifest-plugin: "npm:5.2.1"
serve: "npm:14.2.6"
sinon: "npm:22.0.0"
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
@@ -8885,13 +8885,13 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.8":
version: 11.2.8
resolution: "intl-messageformat@npm:11.2.8"
"intl-messageformat@npm:11.2.7":
version: 11.2.7
resolution: "intl-messageformat@npm:11.2.7"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
"@formatjs/icu-messageformat-parser": "npm:3.5.11"
checksum: 10/451585274cdb1ff798b8d9bfa9aed6623870d2a3bf7c315a4cc37e57793d13a51ec09da705a228f7a64a1267fd6afe91b5bf2fd0c4223fd4f1d158a2adb82d41
"@formatjs/fast-memoize": "npm:3.1.5"
"@formatjs/icu-messageformat-parser": "npm:3.5.10"
checksum: 10/ccd566358c90c7d33fbc71206d46501c18e66dac10cfdab1ea226bc16c3b31d25d987619ef355ca24c5b9f415075d2abe5cf383c69dbbe758bd78e7f72017ab9
languageName: node
linkType: hard
@@ -10221,12 +10221,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.5":
version: 18.0.5
resolution: "marked@npm:18.0.5"
"marked@npm:18.0.4":
version: 18.0.4
resolution: "marked@npm:18.0.4"
bin:
marked: bin/marked.js
checksum: 10/61f1c64304a7e2615ac096bd7b6e5da453561a57393a57f85f8ddef07a64eaeadf4d57c4c6e47d9179d116d36326c159d15993490686fc17434a39bdab9ddb8a
checksum: 10/87d8e309690c9b8e1a5c41487262f19c811e876b8351fbb24432ab348e79c59ee7c68745ec47bfc91c1306a1756077717ff564454e7a68a58a5bd064d732d5dd
languageName: node
linkType: hard
@@ -12019,17 +12019,17 @@ __metadata:
languageName: node
linkType: hard
"rspack-manifest-plugin@npm:5.2.2":
version: 5.2.2
resolution: "rspack-manifest-plugin@npm:5.2.2"
"rspack-manifest-plugin@npm:5.2.1":
version: 5.2.1
resolution: "rspack-manifest-plugin@npm:5.2.1"
dependencies:
"@rspack/lite-tapable": "npm:^1.0.1"
peerDependencies:
"@rspack/core": ^1.0.0 || ^2.0.0
"@rspack/core": ^1.0.0 || ^2.0.0-0
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/1acae737b83bcb16d4d9fa07a2b8542c97844bc7328ebdca4db76d3d010f507eeb9ba05f639dc01961234fedbe99dc49d7c128533b415be13a75b91e21101410
checksum: 10/fe0ddf92a881e45859f8dc4991823e3773292cfbd087db0d83d5856785069b1774139116fe0ca0adc02bdca8f7392165a990b104bc0298f4e1824c9d73672b05
languageName: node
linkType: hard