Compare commits

...
Author SHA1 Message Date
Aidan Timson 8dedee6e70 Migrate 7th set to dirty state provider 2026-06-09 15:25:52 +01:00
9ba34bdf9a Add type and integration context to scene editor entity rows (#52494)
* Show entity area and device in scene editor entity lists

Entities already added to a scene only displayed their friendly name, so
several similarly named entities (e.g. multiple lights named LED) were
indistinguishable. Add a secondary line with the entity's area and device,
reusing computeEntityPickerDisplay so it matches the add-entity picker.
Applies to both the device-grouped and standalone entity lists.

* Add type and integration context to scene editor entity rows

Mirror the pickers used to add scene members: device-grouped entities show
their integration (e.g. Matter), like the device picker, while standalone
entities show their domain (e.g. Light), like the entity picker. Combined with
the area and device on the second line, this keeps entities distinguishable
once they are part of the scene.

---------

Co-authored-by: Przemysław Szypowicz <[email protected]>
2026-06-09 14:36:34 +03:00
Jan-Philipp BeneckeandGitHub f0f28789de Fix scrolling behavior for auto-height data table (#52508) 2026-06-09 14:21:40 +03:00
Jan-Philipp BeneckeandGitHub f007ea9da1 Fix disabled action items icon button color in hui edit mode (#52507) 2026-06-09 14:02:57 +03:00
Aidan TimsonandGitHub 8c51adf77f Revamp design/gallery to use theming and align with app ui (#52495)
* Dedicated gallery AGENTS.md

* Dont auto open the dev server for gallery

* Refactor and theme gallery

* Add icons

* Better positioning of icons

* Reorganise sidebar

* Remove extra title

* Remove header/toolbar height override

* Add some global spacing for content

* Show flipped theme mode for comparisions

* Remove unnesassary headings

* Fix eslint webpack resolution path for gallery vscode import errors

* Scroll item into view

* Fix theme variables

* Fix theme when system theme is dark and set to light

* Review

* Review

* Review

* Review

* Add mock

* Fix buttons
2026-06-09 13:35:56 +03:00
Aidan TimsonandGitHub 876c4d3e2e Dirty state context provider, update more info settings and categories to prevent scrim closure (#52358)
* Dirty state context provider

* Shallow state (new)

* Deep state (existing)

* Fix loop

* remove cast

* Move dirty state provider to dialog build level using deferred state

* Prevent scrim closure on category dirty state

* Discard dirty state on view change

* Move more info outside cache

* Refactor to allow multiple keys in dirty state, use default if not provided

* Fix child view rendering

* Deep clone to avoid mutations

* Fix state timing
2026-06-09 13:09:55 +03:00
cff72770e6 Revamp ZHA device management (#52368)
* Revamp ZHA device management

* Style cleanup

* Fix back navigation

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <[email protected]>

* Process code review

* Fix translation

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Norbert Rittel <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-06-09 06:16:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ff49fa78f8 Update dependency fuse.js to v7.4.2 (#52503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-09 08:12:01 +03:00
Aidan TimsonandGitHub 914b90ffae Add typed query param helpers, add to history and activity (#52439) 2026-06-09 08:11:32 +03:00
ildar170975andGitHub 1f1d520fdf Plant status card: add color for battery (#52457)
add color for battery
2026-06-08 14:16:14 +03:00
Bram KragtenandGitHub 1cca5f3108 Use iOS provided device name for matter, and wait for iOS flow to be … (#52416) 2026-06-08 09:58:23 +02:00
renovate[bot]andGitHub 0859202043 Update dependency rspack-manifest-plugin to v5.2.2 (#52492) 2026-06-08 08:23:51 +01:00
karwostsandGitHub 125629ed39 Prevent spurious error flashes during helpers table load (#52479) 2026-06-08 08:34:18 +03:00
Abílio CostaandGitHub bbfa71e974 Fix "show_legend" default in energy graph card editor (#52486) 2026-06-08 08:31:52 +03:00
3e8c528863 Use attribute name as default label for state selector (#52490)
The state selector previously always defaulted its inner label to the
generic "State". When an attribute is configured (e.g. the source field
of media_player.select_source), default to the attribute's friendly name
instead, falling back to "State" when no concrete entity is available to
resolve the name.

Co-authored-by: Claude <[email protected]>
2026-06-08 08:31:13 +03:00
ffc4731205 Fix lost keyboard focus in virtualized list when scrolling to off-screen row (#52491)
Fix wrong scroll target in virtualized list setActiveItemIndex

When the requested row is outside the rendered range, setActiveItemIndex
scrolled to the raw index argument instead of the clamped/remapped
this.activeItemIndex it had just computed. If the requested index was out
of bounds or pointed at a non-focusable row (remapped to the first
focusable row), it scrolled to the wrong element (or none), so the active
row was never brought into view and keyboard focus was lost.

Use this.activeItemIndex, matching the other two element() call sites.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-08 08:30:09 +03:00
3b699da86c Fix add integration dialog error when picking an integration within a brand (#52489)
Fix missing domain on brand integration list items

When listing the integrations belonging to a brand in the add
integration dialog, the list item was missing the .domain property. The
click handler reads the domain from ev.currentTarget.domain, so it came
through as undefined and was sent to the backend as the config flow
handler, producing "required key not provided @ data['integration']".

This restores the .domain binding that was accidentally dropped in #52354.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-07 22:33:21 -04:00
karwostsandGitHub b316ef5f45 Fix yaml entity autocomplete (#52475) 2026-06-07 20:17:47 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2c3e61b126 Update formatjs monorepo (#52484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-07 17:04:29 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3bd1d45fe1 Update dependency marked to v18.0.5 (#52482)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-07 19:00:55 +02:00
Tim van DalenandGitHub ae66cfc12e Don't show charging devices as low battery in Maintenance chip (#52238)
* Don't show charging devices as low battery in Maintenance chip

* Remove Companion app specific workaround
2026-06-07 07:30:57 -07:00
ildar170975andGitHub de45578c4c hui-entity-editor: add margins to add-button (#52454)
* add margins to add-button

* add margin to ha-selector-select
2026-06-07 10:03:56 +03:00
1221e74776 Fix ha-textarea resize="auto" growing past max-height instead of scrolling (#52461)
* Cap size-adjuster height in ha-textarea resize=auto

When resize="auto", Web Awesome's textarea base is a CSS grid where the
real textarea and the size-adjuster share one cell, and both receive an
inline height equal to the content scrollHeight. We only capped the
textarea's max-height, so with long content the size-adjuster inflated
the grid row and the centered textarea was pushed down instead of
scrolling. Apply the same max-height cap to the textarea-adjuster part.

* Add capped autogrow demo to ha-textarea gallery page

Demonstrates resize="auto" with content that overflows the max-height,
so the textarea scrolls instead of growing unbounded. Serves as a visual
regression guard for the size-adjuster grid-row fix.

---------

Co-authored-by: Claude <[email protected]>
2026-06-07 09:55:02 +03:00
04ae6eb3b4 Always show volume controls for assumed state media players in more info (#52466)
Co-authored-by: Claude <[email protected]>
2026-06-07 09:54:06 +03:00
karwostsandGitHub 089849d283 Improve table no-data messages (#52474) 2026-06-07 09:52:19 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
70a20d8bcc Bump release-drafter/release-drafter from 7.3.0 to 7.3.1 (#52476)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/c2e2804cc59f45f57076a99af580d0fedb697927...693d20e7c1ce1a81d3a41962f85914253b518449)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 08:19:22 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6c448be3f1 Update CodeMirror to v6.20.3 (#52472)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-06 18:19:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c6e5ae21e2 Update tsparticles to v4.1.3 (#52473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-06 18:18:53 +02:00
b8ac4f3f3e No animation for running state (#52455)
* No animation for runnig state

* Remove state-dot-pulse animation and media query

Removed unused keyframes and media query for animation.

---------

Co-authored-by: Paulus Schoutsen <[email protected]>
2026-06-06 11:42:55 -04:00
fab4022dee Remove unused PageNavigation.not_component (#52463)
Co-authored-by: Claude <[email protected]>
2026-06-06 11:37:55 +02:00
karwostsandGitHub d70a42930b Fix helper no category message (#52462) 2026-06-06 11:35:09 +02:00
Paulus SchoutsenandGitHub b91a087ab1 Fix oversized top-app-bar-fixed-adjust wrapper in gallery pages (#52467) 2026-06-06 08:00:39 +02:00
95 changed files with 5118 additions and 2501 deletions
+7 -17
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 detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
**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).
## Table of Contents
@@ -338,11 +338,6 @@ 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[]`
@@ -361,10 +356,6 @@ Common patterns:
></ha-form>
```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
@@ -378,10 +369,6 @@ 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.
@@ -405,7 +392,6 @@ 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
@@ -435,7 +421,7 @@ export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
**Purpose**: Cards allow users to tell different stories about their house.
```typescript
@customElement("hui-my-card")
@@ -508,6 +494,10 @@ 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
@@ -538,7 +528,7 @@ When creating a pull request, you **must** use the PR template located at `.gith
#### Terminology Standards
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
**Delete vs Remove**
- **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@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+24 -3
View File
@@ -33,7 +33,9 @@ const isWsl =
* compiler: import("@rspack/core").Compiler,
* contentBase: string,
* port: number,
* listenHost?: string
* listenHost?: string,
* open?: boolean,
* logUrlAfterFirstBuild?: boolean,
* }}
*/
const runDevServer = async ({
@@ -41,16 +43,31 @@ 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: true,
open,
host: listenHost,
port,
static: {
@@ -70,7 +87,9 @@ const runDevServer = async ({
await server.start();
// Server listening
log("[rspack-dev-server]", `Project is running at http://localhost:${port}`);
if (!logUrlAfterFirstBuild) {
log("[rspack-dev-server]", `Project is running at ${url}`);
}
};
const doneHandler = (done) => (err, stats) => {
@@ -172,6 +191,8 @@ gulp.task("rspack-dev-server-gallery", () =>
contentBase: paths.gallery_output_root,
port: 8100,
listenHost: "0.0.0.0",
open: false,
logUrlAfterFirstBuild: true,
})
);
+7 -1
View File
@@ -1,5 +1,7 @@
// @ts-check
import { fileURLToPath } from "node:url";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import js from "@eslint/js";
@@ -11,6 +13,10 @@ 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,
@@ -50,7 +56,7 @@ export default tseslint.config(
settings: {
"import-x/resolver": {
webpack: {
config: "./rspack.config.cjs",
config: rspackConfigPath,
},
},
},
+112
View File
@@ -0,0 +1,112 @@
# 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.
+44 -18
View File
@@ -1,20 +1,50 @@
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",
@@ -24,33 +54,29 @@ 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: "design.home-assistant.io",
header: "About",
category: "misc",
icon: mdiDotsHorizontal,
header: "Miscellaneous",
pages: [
"entity-state",
"ha-markdown",
"integration-card",
"box-shadow",
"util-long-press",
"remove-delete-add-create",
"editing",
],
},
];
+121
View File
@@ -0,0 +1,121 @@
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";
};
+130 -61
View File
@@ -1,25 +1,83 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, css, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
import { customElement, property, query, state } from "lit/decorators";
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() value?: any;
@property({ attribute: false }) value?: unknown;
@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">
<div class="content light">
<section class="content current" aria-label=${currentLabel}>
<h2>${currentLabel}</h2>
<ha-card .header=${this.title}>
<div class="card-content">
<slot name="light"></slot>
@@ -30,8 +88,9 @@ class DemoBlackWhiteRow extends LitElement {
</ha-button>
</div>
</ha-card>
</div>
<div class="content dark">
</section>
<section class="content flipped" aria-label=${flippedLabel}>
<h2>${flippedLabel}</h2>
<ha-card .header=${this.title}>
<div class="card-content">
<slot name="dark"></slot>
@@ -45,65 +104,84 @@ class DemoBlackWhiteRow extends LitElement {
${this.value
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
: nothing}
</div>
</section>
</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
);
}
handleSubmit(ev: Event) {
const content = (ev.target as HaButton).closest(".content");
if (!content) {
return;
}
handleSubmit(ev) {
const content = (ev.target as HaButton).closest(".content")!;
fireEvent(this, "submitted" as any, {
slot: content.classList.contains("light") ? "light" : "dark",
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
);
}
static styles = css`
:host {
display: block;
flex: 1;
min-block-size: 100%;
}
.row {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
inline-size: 100%;
min-block-size: 100%;
}
.content {
padding: 50px 0;
box-sizing: border-box;
min-inline-size: 0;
padding: var(--ha-space-8);
background-color: var(--primary-background-color);
}
.light {
flex: 1;
padding-left: 50px;
padding-right: 50px;
box-sizing: border-box;
}
.light ha-card {
margin-left: auto;
}
.dark {
color: var(--primary-text-color);
display: flex;
flex: 1;
padding-left: 50px;
box-sizing: border-box;
flex-wrap: wrap;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-card {
width: 400px;
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);
}
pre {
width: 300px;
margin: 0 16px 0;
box-sizing: border-box;
width: 100%;
margin: 0;
overflow: auto;
color: var(--primary-text-color);
}
@@ -112,27 +190,18 @@ class DemoBlackWhiteRow extends LitElement {
flex-direction: row-reverse;
border-top: none;
}
@media only screen and (max-width: 1500px) {
.light {
flex: initial;
}
}
@media only screen and (max-width: 1000px) {
.light,
.dark {
.row {
grid-template-columns: 1fr;
}
.content {
padding: 16px;
}
.row,
.dark {
flex-direction: column;
}
ha-card {
margin: 0 auto;
width: 100%;
max-width: 400px;
}
pre {
margin: 16px auto;
margin: 0;
}
}
`;
+1 -13
View File
@@ -1,6 +1,5 @@
import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
@@ -16,17 +15,12 @@ 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">
@@ -48,12 +42,6 @@ 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;
+2 -23
View File
@@ -1,6 +1,5 @@
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";
@@ -21,9 +20,6 @@ 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">
@@ -51,33 +47,16 @@ class DemoMoreInfos extends LitElement {
justify-content: center;
}
demo-more-info {
margin: 16px 16px 32px;
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
}
ha-formfield {
margin-right: 16px;
margin-right: var(--ha-space-4);
}
`;
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 {
@@ -0,0 +1,153 @@
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;
}
}
@@ -0,0 +1,87 @@
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;
}
}
+4 -14
View File
@@ -13,13 +13,10 @@ class PageDescription extends HaMarkdown {
return nothing;
}
const subtitle = PAGES[this.page].metadata.subtitle;
return html`
<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>
${subtitle ? html`<div class="subtitle">${subtitle}</div>` : nothing}
${until(
PAGES[this.page]
.description()
@@ -32,16 +29,9 @@ class PageDescription extends HaMarkdown {
static styles = [
HaMarkdown.styles,
css`
.heading {
.subtitle {
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);
}
+2 -15
View File
@@ -16,22 +16,9 @@ class HaDemoOptions extends LitElement {
css`
:host {
display: block;
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);
background-color: var(--primary-background-color);
position: relative;
height: 64px;
padding: 0 16px;
pointer-events: none;
padding: var(--ha-space-2) var(--ha-space-16) var(--ha-space-1);
font-size: var(--ha-font-size-xl);
}
`,
+533 -156
View File
@@ -1,161 +1,183 @@
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } 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/";
const FAKE_HASS = {
// Just enough for computeRTL for notification-manager
language: "en",
translationMetadata: {
translations: {},
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);
},
};
sendMessagePromise() {
return Promise.resolve({ value: null });
},
} as unknown as Connection;
@customElement("ha-gallery")
class HaGallery extends LitElement {
@state() private _page =
document.location.hash.substring(1) ||
`${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
@state() private _page = this._pageFromLocation();
@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-drawer")
private _drawer!: HaDrawer;
@query("ha-sidebar")
private _sidebar?: HTMLElementTagNameMap["ha-sidebar"];
@query(".gallery-nav-item[selected]")
private _selectedNavigationItem?: HTMLElementTagNameMap["ha-list-item-button"];
private _narrow = window.matchMedia("(max-width: 600px)").matches;
@state() private _drawerOpen = !this._narrow;
render() {
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
);
}
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
return html`
<ha-drawer
.direction=${this._rtl ? "rtl" : "ltr"}
.open=${!this._narrow}
.open=${this._drawerOpen}
.type=${this._narrow ? "modal" : "dismissible"}
>
<div class="drawer-title">Home Assistant Design</div>
<div class="sidebar">${sidebar}</div>
<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 slot="appContent" class="app-content">
<ha-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@click=${this._menuTapped}
.path=${mdiMenu}
></ha-icon-button>
<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}
<div slot="title">
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
${isSettingsPage
? "Settings"
: 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=${FAKE_HASS}
.hass=${this._galleryHass}
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 })
@@ -171,16 +193,26 @@ class HaGallery extends LitElement {
}
});
document.location.hash = this._page;
if (document.location.hash.substring(1) !== this._page) {
document.location.hash = this._page;
}
window.addEventListener("hashchange", () => {
this._page = document.location.hash.substring(1);
if (this._narrow) {
this._drawer.open = false;
}
});
window.addEventListener("hashchange", this._hashChanged);
}
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);
@@ -188,37 +220,335 @@ 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();
}
const menuItem = this.shadowRoot!.querySelector(
`a[href="#${this._page}"]`
)!;
void this._scrollSelectedNavigationItemIntoView();
}
// Make sure section is expanded
private async _scrollSelectedNavigationItemIntoView() {
const menuItem = this._selectedNavigationItem;
if (!menuItem) {
return;
}
// Make sure section is expanded before measuring the selected item.
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 _menuTapped() {
this._drawer.open = !this._drawer.open;
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 _toggleRtl() {
this._rtl = !this._rtl;
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
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 _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`
@@ -226,49 +556,103 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
--header-height: 64px;
--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);
}
.sidebar {
.gallery-sidebar-section {
color: var(--sidebar-text-color);
box-sizing: border-box;
max-height: calc(100vh - var(--header-height));
overflow-y: auto;
padding: 4px;
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);
}
.drawer-title {
align-items: center;
.gallery-sidebar-section::part(summary) {
min-height: var(--ha-space-10);
border-radius: var(--ha-border-radius-sm);
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;
}
.sidebar a {
color: var(--primary-text-color);
display: block;
padding: 12px;
text-decoration: none;
.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);
position: relative;
width: var(--ha-sidebar-expanded-item-width, 248px);
color: var(--sidebar-text-color);
}
.sidebar a[active]::before {
border-radius: var(--ha-border-radius-lg);
.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);
position: absolute;
top: 0;
right: 2px;
right: 0;
bottom: 0;
left: 2px;
left: 0;
pointer-events: none;
content: "";
transition: opacity 15ms linear;
will-change: opacity;
background-color: var(--sidebar-selected-icon-color);
opacity: 0.12;
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);
}
.app-content {
@@ -283,11 +667,16 @@ class HaGallery extends LitElement {
}
.content {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1;
padding-top: var(--ha-space-4);
}
page-description {
margin: 16px;
display: block;
margin: 0 var(--ha-space-4) var(--ha-space-4);
}
.page-footer {
@@ -324,18 +713,6 @@ 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);
}
`,
];
}
+11 -35
View File
@@ -1,11 +1,11 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-alert ${mode} demo">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
${alerts.map(
(alert) => html`
@@ -154,43 +154,19 @@ export class DemoHaAlert extends LitElement {
)}
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
ha-alert {
display: block;
+12 -34
View File
@@ -1,12 +1,12 @@
import { mdiButtonCursor, mdiHome } from "@mdi/js";
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-badge ${mode} demo">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
${badges.map(
(badge) => html`
@@ -78,45 +78,23 @@ export class DemoHaBadge extends LitElement {
)}
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-6);
}
`;
+12 -34
View File
@@ -1,13 +1,13 @@
import { mdiHome } from "@mdi/js";
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
${variants.map(
(variant) => html`
@@ -112,45 +112,22 @@ export class DemoHaButton extends LitElement {
)}
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
@@ -159,6 +136,7 @@ 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 header="ha-chip demo">
<ha-card>
<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,9 +9,11 @@ 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;
@@ -45,106 +47,72 @@ const switches: {
export class DemoHaControlSwitch extends LitElement {
@state() private checked = false;
handleValueChanged(e: any) {
this.checked = e.target.checked as boolean;
handleValueChanged(e: HASSDomTargetEvent<HaControlSwitch>) {
this.checked = e.target.checked;
}
protected render(): TemplateResult {
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>
<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`
<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>
</ha-card>
</div>
</div>
</ha-card>
`
)}
</div>
</demo-theme-comparison>
`;
}
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 {
max-width: 600px;
margin: 0 auto;
margin: 0;
width: 100%;
}
pre {
margin-top: 0;
+11 -34
View File
@@ -8,25 +8,25 @@ import {
mdiContentPaste,
mdiDelete,
} from "@mdi/js";
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
<ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
@@ -74,45 +74,22 @@ export class DemoHaDropdown extends LitElement {
</ha-dropdown>
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.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 header="ha-faded demo">
<ha-card>
<div class="card-content">
<h3>Long text directly as slotted content</h3>
<ha-faded>${LONG_TEXT}</ha-faded>
+177 -163
View File
@@ -1,9 +1,8 @@
import { ContextProvider } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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";
@@ -11,6 +10,15 @@ 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",
@@ -22,6 +30,25 @@ 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() {
@@ -29,185 +56,171 @@ export class DemoHaInput extends LitElement {
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
new ContextProvider(this, {
context: internationalizationContext,
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,
},
initialValue: DEMO_I18N,
});
}
protected render(): TemplateResult {
return html`
${["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>
<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>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>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>
<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>
<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>
<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>
</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>
`
)}
</ha-card>
</div>
`
)}
</demo-theme-comparison>
`;
}
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;
}
.panel-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
}
ha-card {
margin: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
@@ -224,10 +237,11 @@ export class DemoHaInput extends LitElement {
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
}
.row > * {
flex: 1;
flex: 1 1 180px;
}
`;
}
@@ -1,20 +1,21 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-progress-button in ${mode}">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
<ha-progress-button @click=${this._clickedSuccess}>
Success
@@ -59,32 +60,17 @@ export class DemoHaProgressButton extends LitElement {
</ha-progress-button>
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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> {
private _clickedSuccess(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-progress-button"]>
) {
console.log("Clicked success");
const button = ev.currentTarget as any;
const button = ev.currentTarget;
button.progress = true;
setTimeout(() => {
@@ -93,8 +79,10 @@ export class DemoHaProgressButton extends LitElement {
}, 1000);
}
private async _clickedFail(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
private _clickedFail(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-progress-button"]>
) {
const button = ev.currentTarget;
button.progress = true;
setTimeout(() => {
@@ -105,20 +93,14 @@ 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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
+11 -36
View File
@@ -1,12 +1,12 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-slider ${mode} demo">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
<span>Default (disabled)</span>
<ha-slider
@@ -45,44 +45,19 @@ export class DemoHaSlider extends LitElement {
></ha-slider>
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
+11 -36
View File
@@ -1,11 +1,11 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-badge ${mode} demo">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
<ha-spinner></ha-spinner>
<ha-spinner size="tiny"></ha-spinner>
@@ -27,44 +27,19 @@ export class DemoHaSpinner extends LitElement {
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner>
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
+11 -36
View File
@@ -1,10 +1,10 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-switch ${mode}">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
<div class="row">
<span>Unchecked</span>
@@ -35,44 +35,19 @@ export class DemoHaSwitch extends LitElement {
</div>
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
+21 -34
View File
@@ -1,18 +1,23 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { TemplateResult } 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`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-textarea in ${mode}">
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
<h3>Basic</h3>
<div class="row">
@@ -38,6 +43,11 @@ 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>
@@ -84,42 +94,19 @@ export class DemoHaTextarea extends LitElement {
</div>
</div>
</ha-card>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
.card-content {
display: flex;
+44 -48
View File
@@ -1,12 +1,19 @@
import { provide } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import type { 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",
@@ -14,68 +21,57 @@ 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: 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 _i18n = DEMO_I18N;
protected render(): TemplateResult {
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
);
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>
`;
}
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: 24px auto;
margin: 0;
width: 100%;
}
`;
}
+21 -57
View File
@@ -1,7 +1,6 @@
import type { 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 { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const SHADOWS = ["s", "m", "l"] as const;
@@ -9,67 +8,32 @@ const SHADOWS = ["s", "m", "l"] as const;
export class DemoMiscBoxShadow extends LitElement {
protected render() {
return html`
${["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>
`
)}
<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>
</div>
</div>
`
)}
`
)}
</demo-theme-comparison>
`;
}
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;
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;
display: block;
}
.grid {
@@ -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`. It will automatically open a browser window and load 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`. After the first build finishes, the command prints the local URL for the development version of the website.
## Creating a page
+16 -16
View File
@@ -29,7 +29,7 @@
"dependencies": {
"@babel/runtime": "7.29.7",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/autocomplete": "6.20.3",
"@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.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",
"@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",
"@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.2",
"@tsparticles/preset-links": "4.1.2",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@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.1",
"fuse.js": "7.4.2",
"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.7",
"intl-messageformat": "11.2.8",
"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.4",
"marked": "18.0.5",
"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.1",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.16",
+1 -11
View File
@@ -4,8 +4,7 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
(isCore(page) || isLoadedIntegration(hass, page)) &&
isNotLoadedIntegration(hass, page);
isCore(page) || isLoadedIntegration(hass, page);
export const isLoadedIntegration = (
hass: HomeAssistant,
@@ -16,13 +15,4 @@ 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;
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,140 @@
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 -15
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,7 +366,10 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
if (properties.has("selectable") || properties.has("hiddenColumns")) {
if (
this._filteredData &&
(properties.has("selectable") || properties.has("hiddenColumns"))
) {
this._filteredData = [...this._filteredData];
}
}
@@ -409,6 +412,8 @@ 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}>
@@ -429,10 +434,10 @@ export class HaDataTable extends LitElement {
"auto-height": this.autoHeight,
})}"
role="table"
aria-rowcount=${this._filteredData.length + 1}
aria-rowcount=${filteredDataLength + 1}
style=${styleMap({
height: this.autoHeight
? `${(this._filteredData.length || 1) * 53 + 53}px`
? `${(filteredDataLength || 1) * 53 + 53}px`
: `calc(100% - ${this._headerHeight}px)`,
})}
>
@@ -521,16 +526,23 @@ 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.noDataText ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"}
${!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"}
</div>
</div>
</div>
@@ -903,7 +915,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,
@@ -1005,7 +1017,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", {
@@ -1465,6 +1477,11 @@ 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,6 +115,20 @@ 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;
@@ -129,8 +143,7 @@ export class HaEntityStatePicker extends LitElement {
.disabled=${this.disabled || noEntity}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??
this.hass.localize("ui.components.entity.entity-state-picker.state")}
.label=${this.label ?? this._computeDefaultLabel()}
.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.label
.toLowerCase()
entityState.displayLabel
?.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.label
.toLowerCase()
entityState.displayLabel
?.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
+34 -14
View File
@@ -170,6 +170,9 @@ 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;
@@ -346,8 +349,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@action=${this._toggleSidebar}
></ha-icon-button>
`
: ""}
<div class="title">Home Assistant</div>
: nothing}
<div class="title">${this.sidebarTitle}</div>
</div>`;
}
@@ -362,16 +365,28 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>`;
if (!this._panelOrder || !this._hiddenPanels) {
return html`
<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>
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()}
${renderList(
html`${this._renderFixedPanels(selectedPanel)}`,
html`<slot name="fixed-navigation">
${this._renderFixedPanels(selectedPanel)}
</slot>`,
"after-spacer",
false
)}
`;
</div>`;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
@@ -388,7 +403,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
return html`<div class="panels-list">
<div class="wrapper">
${renderList(
this._renderPanels(beforeSpacer, selectedPanel),
html`<slot name="main-navigation">
${this._renderPanels(beforeSpacer, selectedPanel)}
</slot>`,
"before-spacer",
true
)}
@@ -396,10 +413,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
</div>
${this._renderSpacer()}
${renderList(
html`
html`<slot name="fixed-navigation">
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderFixedPanels(selectedPanel)}
`,
</slot>`,
"after-spacer",
false
)}
@@ -541,7 +558,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 : ""}</span
>${this.hass.user ? this.hass.user.name : nothing}</span
>
</ha-list-item-button>
${!this.alwaysExpand && this.hass.user
@@ -665,7 +682,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
transition: width var(--ha-animation-duration-normal) ease;
}
:host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left, 0px));
width: calc(
var(--ha-sidebar-expanded-width, 256px) +
var(--safe-area-inset-left, 0px)
);
}
:host([narrow][expanded]) .menu {
width: 100%;
@@ -748,7 +768,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
color: var(--sidebar-text-color);
}
:host([expanded]) ha-list-item-button {
width: 248px;
width: var(--ha-sidebar-expanded-item-width, 248px);
}
:host([narrow][expanded]) ha-list-item-button {
width: calc(240px - var(--safe-area-inset-left, 0px));
+8
View File
@@ -258,6 +258,14 @@ 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
@@ -0,0 +1,255 @@
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(index)
?.element(this.activeItemIndex)
?.scrollIntoView({ block: "nearest" });
}
}
+31
View File
@@ -0,0 +1,31 @@
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");
@@ -116,12 +116,14 @@ 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!)
(stateActive(this.stateObj!) || assumedState)
? html`
<div class="volume">
${supportsMute
+78 -56
View File
@@ -63,6 +63,9 @@ 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 {
@@ -121,9 +124,10 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends SubscribeMixin(
ScrollableFadeMixin(LitElement)
) {
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | null,
"entity-registry" | "helper"
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -633,6 +637,18 @@ export class MoreInfoDialog extends SubscribeMixin(
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}
@@ -640,7 +656,8 @@ export class MoreInfoDialog extends SubscribeMixin(
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@show-child-view=${this._showChildView}
.preventScrimClose=${this._currView === "settings" ||
.preventScrimClose=${(this._currView === "settings" &&
this.isDirtyState) ||
!this._isEscapeEnabled}
flexcontent
>
@@ -863,70 +880,65 @@ export class MoreInfoDialog extends SubscribeMixin(
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${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"
${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"
? html`
<ha-more-info-settings
<ha-more-info-info
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
.editMode=${this._infoEditMode}
.data=${this._data}
></ha-more-info-info>
`
: this._currView === "related"
: this._currView === "history"
? html`
<ha-related-items
<ha-more-info-history-and-logbook
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
`
: this._currView === "add_to"
: this._currView === "related"
? html`
<ha-more-info-add-to
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
<ha-related-items
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
`
: this._currView === "details"
: this._currView === "add_to"
? html`
<ha-more-info-details
.hass=${this.hass}
.entry=${this._entry}
.params=${{ entityId }}
.yamlMode=${this._detailsYamlMode}
></ha-more-info-details>
<ha-more-info-add-to
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
`
: nothing
)}
: this._currView === "details"
? html`
<ha-more-info-details
.hass=${this.hass}
.entry=${this._entry}
.params=${{ entityId }}
.yamlMode=${this._detailsYamlMode}
></ha-more-info-details>
`
: nothing
)}
</div>
`
)}
@@ -949,6 +961,10 @@ export class MoreInfoDialog extends SubscribeMixin(
| MoreInfoView
| undefined;
if (previousView === "settings" && this._currView !== "settings") {
this._discardDirtyStateChanges();
}
if (previousView === "details" && this._currView !== "details") {
const dialog =
this._dialogElement?.shadowRoot?.querySelector("ha-dialog");
@@ -957,6 +973,12 @@ export class MoreInfoDialog extends SubscribeMixin(
}
}
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,6 +5,7 @@ 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";
@@ -15,6 +16,7 @@ import type {
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageCommands,
ImprovDiscoveredDevice,
MatterCommissionFinish,
} from "./external_messaging";
const barCodeListeners = new Set<
@@ -91,6 +93,8 @@ 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") {
@@ -115,5 +119,10 @@ 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,6 +320,18 @@ 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
@@ -331,6 +343,7 @@ export type EMIncomingMessageCommands =
| EMIncomingMessageBarCodeScanAborted
| EMIncomingMessageImprovDeviceDiscovered
| EMIncomingMessageImprovDeviceSetupDone
| EMIncomingMessageMatterCommissionFinish
| EMIncomingMessageKioskModeSet;
type EMIncomingMessage =
@@ -346,6 +359,7 @@ export interface ExternalConfig {
canWriteTag?: boolean;
hasExoPlayer?: boolean;
canCommissionMatter?: boolean;
hasMatterStatusReport?: boolean;
canImportThreadCredentials?: boolean;
canTransferThreadCredentialsToKeychain?: boolean;
hasAssist?: boolean;
+12
View File
@@ -6,6 +6,7 @@ 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,
@@ -20,6 +21,7 @@ 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";
@@ -33,6 +35,12 @@ 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>);
@@ -415,6 +423,10 @@ 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,7 +33,6 @@ 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
@@ -0,0 +1,190 @@
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;
};
+4 -6
View File
@@ -6,6 +6,9 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
superClass: T
) =>
class extends superClass {
/** Provided by `DirtyStateProviderMixin` or overridden by the consuming class. */
declare isDirtyState: boolean;
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
@@ -33,7 +36,7 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirty) {
if (this.isDirtyState) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
@@ -47,11 +50,6 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
this._removeListeners();
}
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
protected get isDirty(): boolean {
return false;
}
protected async promptDiscardChanges(): Promise<boolean> {
return true;
}
@@ -45,7 +45,6 @@ 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);
@@ -56,19 +55,6 @@ 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;
}
}
`;
}
+59 -12
View File
@@ -1,22 +1,55 @@
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
import type { DirtyStateContext } from "../../../data/context/dirty-state";
import { dirtyStateContext } from "../../../data/context/dirty-state";
import type { ShowToastParams } from "../../../managers/notification-manager";
import { showToast } from "../../../util/toast";
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
/**
* Mixin that consumes `dirtyStateContext` and exposes `editorDirty` for use
* in determining toast offset positioning.
*/
export const EditorToastDirtyConsumerMixin = <
Base extends abstract new (...args: any[]) => LitElement,
>(
superClass: Base
) => {
abstract class EditorToastDirtyConsumer extends superClass {
@state()
@consume({ context: dirtyStateContext, subscribe: true })
private _dirtyCtx?: DirtyStateContext;
protected get editorDirty(): boolean {
return Boolean(this._dirtyCtx?.isDirty);
}
protected showEditorToast(params: ShowToastParams): void {
const offset = this.editorDirty
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
: undefined;
showToast(this, {
...params,
...(offset !== undefined ? { bottomOffset: offset } : {}),
dismissable: true,
});
}
}
return EditorToastDirtyConsumer as unknown as Base &
(abstract new (...args: any[]) => {
editorDirty: boolean;
showEditorToast(params: ShowToastParams): void;
});
};
/**
* Standalone function for callers that haven't adopted `EditorToastDirtyConsumerMixin`.
* Falls back to DOM traversal for dirty detection.
* @deprecated Use `EditorToastDirtyConsumerMixin` instead.
*/
export function showEditorToast(
el: HTMLElement,
params: ShowToastParams
@@ -30,3 +63,17 @@ export function showEditorToast(
dismissable: true,
});
}
/** @deprecated Use `EditorToastDirtyConsumerMixin` to consume dirty state from context. */
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
@@ -20,6 +20,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
@@ -87,7 +88,9 @@ export interface EditorDomainHooks<TConfig> {
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends superClass {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<boolean>()(
superClass
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -144,8 +147,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
private _relatedContextAreaId?: string;
connectedCallback(): void {
super.connectedCallback();
this._initDirtyTracking({ type: "shallow" }, false);
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("dirty")) {
this._updateDirtyState(this.dirty);
}
if (
changedProps.has("currentEntityId") ||
changedProps.has("entityRegistry")
@@ -237,10 +248,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
};
protected get isDirty() {
return this.dirty;
}
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
}
@@ -15,13 +15,19 @@ 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 DialogMixin<CategoryRegistryDetailDialogParams>(
LitElement
class DialogCategoryDetail extends DirtyStateProviderMixin<CategoryFormState>()(
DialogMixin<CategoryRegistryDetailDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -44,6 +50,10 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
this._name = this.params?.suggestedName || "";
this._icon = null;
}
this._initDirtyTracking(
{ type: "shallow" },
{ name: this._name, icon: this._icon }
);
}
protected render() {
@@ -52,13 +62,14 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
}
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")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -96,7 +107,9 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
.disabled=${nameInvalid ||
!!this._submitting ||
(!isCreate && !this.isDirtyState)}
>
${entry
? this._i18n.localize("ui.common.save")
@@ -114,15 +127,17 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
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 {
@@ -131,10 +146,11 @@ class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParam
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,7 +10,6 @@ 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";
@@ -64,8 +63,7 @@ export const getZHADeviceActions = async (
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.manage"),
icon: mdiGroup,
action: () =>
showZHAManageZigbeeDeviceDialog(el, { device: zhaDevice }),
action: () => navigate(`/config/zha/device/${zhaDevice.ieee}/clusters`),
},
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.view_network"),
@@ -1,17 +1,22 @@
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 } from "../../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import type { Helper } from "../../../helpers/const";
import "../../../helpers/forms/ha-counter-form";
import "../../../helpers/forms/ha-input_boolean-form";
@@ -33,21 +38,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(
@@ -60,13 +65,9 @@ export class EntitySettingsHelperTab extends LitElement {
super.updated(changedProperties);
if (changedProperties.has("entry")) {
this._error = undefined;
if (
this.entry.unique_id !==
(changedProperties.get("entry") as ExtEntityRegistryEntry)?.unique_id
) {
if (this.entry.unique_id !== changedProperties.get("entry")?.unique_id) {
this._item = undefined;
}
this._getItem();
}
}
@@ -107,7 +108,6 @@ 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._dirty ||
.disabled=${!this._dirtyState?.isDirty ||
!!this._submitting ||
!!(this._item && !this._item.name)}
>
@@ -134,48 +134,36 @@ export class EntitySettingsHelperTab extends LitElement {
`;
}
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 {
private _valueChanged(ev: ValueChangedEvent<Helper>): void {
if (this._item === null) {
return;
}
this._error = undefined;
this._item = ev.detail.value;
this._updateDirty();
this._dirtyState?.setState(this._item, "helper");
}
private async _getItem() {
const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!);
this._item = items.find((item) => item.id === this.entry.unique_id) || null;
this._originalItemJson = this._item
? JSON.stringify(this._item)
: undefined;
const item =
items.find((helper) => helper.id === this.entry.unique_id) || null;
this._item = item;
this._dirtyState?.setState(item, "helper");
}
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,6 +45,10 @@ 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 {
@@ -144,6 +148,28 @@ 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;
@@ -158,41 +184,53 @@ 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!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@state() private _deviceClass?: string;
@state() private _deviceClass?: EntitySettingsState["deviceClass"];
@state() private _switchAsDomain = "switch";
@state() private _switchAsDomain: EntitySettingsState["switchAsDomain"] =
"switch";
@state() private _switchAsInvert = false;
@state() private _switchAsInvert: EntitySettingsState["switchAsInvert"] =
false;
@state() private _areaId?: string | null;
@state() private _labels?: string[] | null;
@state() private _disabledBy!: EntityRegistryEntry["disabled_by"];
@state() private _disabledBy!: EntitySettingsState["disabledBy"];
@state() private _hiddenBy!: EntityRegistryEntry["hidden_by"];
@state() private _hiddenBy!: EntitySettingsState["hiddenBy"];
@state() private _device?: DeviceRegistryEntry;
@state() private _unit_of_measurement?: string | null;
@state()
private _unit_of_measurement?: EntitySettingsState["unitOfMeasurement"];
@state() private _precision?: number | null;
@state() private _precision?: EntitySettingsState["precision"];
@state() private _precipitation_unit?: string | null;
@state()
private _precipitation_unit?: EntitySettingsState["precipitationUnit"];
@state() private _pressure_unit?: string | null;
@state() private _pressure_unit?: EntitySettingsState["pressureUnit"];
@state() private _temperature_unit?: string | null;
@state()
private _temperature_unit?: EntitySettingsState["temperatureUnit"];
@state() private _visibility_unit?: string | null;
@state() private _visibility_unit?: EntitySettingsState["visibilityUnit"];
@state() private _wind_speed_unit?: string | null;
@state() private _wind_speed_unit?: EntitySettingsState["windSpeedUnit"];
@state() private _cameraPrefs?: CameraPreferences;
@@ -204,9 +242,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _weatherConvertibleUnits?: WeatherUnits;
@state() private _defaultCode?: string | null;
@state() private _defaultCode?: EntitySettingsState["defaultCode"];
@state() private _calendarColor?: string | null;
@state() private _calendarColor?: EntitySettingsState["calendarColor"];
@state() private _associatedZone?: string;
@@ -216,34 +254,6 @@ 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 (
@@ -315,9 +325,6 @@ 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)) {
@@ -365,6 +372,45 @@ 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);
@@ -408,25 +454,6 @@ 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() {
@@ -1144,10 +1171,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
`;
}
public get dirty(): boolean {
return JSON.stringify(this._currentState()) !== this._initialStateJson;
}
public async updateEntry(): Promise<{
close: boolean;
entry: ExtEntityRegistryEntry;
@@ -1433,12 +1456,10 @@ 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;
}
@@ -1457,22 +1478,18 @@ 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
@@ -1480,43 +1497,35 @@ 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;
}
@@ -1551,7 +1560,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private _areaPicked(ev: CustomEvent) {
fireEvent(this, "change");
this._areaId = ev.detail.value;
}
@@ -1624,8 +1632,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
fireEvent(this, "change");
this._openDeviceSettings();
}
@@ -2,6 +2,7 @@ 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";
@@ -13,6 +14,10 @@ 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 {
@@ -38,14 +43,16 @@ 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;
@@ -133,7 +140,6 @@ 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">
@@ -148,7 +154,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
</ha-button>
<ha-button
@click=${this._updateEntry}
.disabled=${!this._dirty || !!this._submitting}
.disabled=${!this._dirtyState?.isDirty || !!this._submitting}
.loading=${!!this._submitting}
>
${this.hass.localize("ui.dialogs.entity_registry.editor.update")}
@@ -157,11 +163,6 @@ 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!];
@@ -207,8 +208,10 @@ 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");
}
+10 -6
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,6 +830,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
}
private _applyFilters() {
if (!this._helperEntities) {
return;
}
const filters = Object.entries(this._filters);
let items: Set<string> | undefined;
@@ -1003,10 +1006,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
if (!entityReg) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.picker.no_category_support"
"ui.panel.config.helpers.picker.no_category_support"
),
text: this.hass.localize(
"ui.panel.config.automation.picker.no_category_entity_reg"
"ui.panel.config.helpers.picker.no_category_entity_reg"
),
});
return;
@@ -1224,7 +1227,7 @@ ${rejected
this._setFiltersFromUrl();
}
if (!this._entityEntries || !this._configEntries) {
if (!this._entityEntries || !this._configEntries || !this._entitySource) {
return;
}
@@ -1265,6 +1268,7 @@ ${rejected
);
if (
!this._helperEntities ||
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
) {
@@ -151,6 +151,7 @@ class HaDomainIntegrations extends LitElement {
.map(
([dom, val]) =>
html`<ha-integration-list-item
.domain=${dom}
.integration=${{
...val,
domain: dom,
@@ -2,6 +2,7 @@ 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";
@@ -10,6 +11,7 @@ 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,
@@ -82,6 +84,10 @@ class DialogMatterAddDevice extends LitElement {
@state() private _mainEntity?: ExtEntityRegistryEntry;
@state() private _proposedDeviceName?: string;
@state() private _commissioningFinished = false;
@state() private _deviceAddedState: {
name: string;
area: string | undefined;
@@ -104,15 +110,63 @@ 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 (
@@ -160,6 +214,8 @@ class DialogMatterAddDevice extends LitElement {
this._newDevice = undefined;
this._mainEntity = undefined;
this._mainEntityFetched = false;
this._proposedDeviceName = undefined;
this._commissioningFinished = false;
this._deviceAddedState = {
name: "",
area: undefined,
@@ -168,6 +224,10 @@ class DialogMatterAddDevice extends LitElement {
};
this._unsub?.();
this._unsub = undefined;
window.removeEventListener(
"matter-commission-finish",
this._handleCommissionFinish
);
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -211,6 +271,7 @@ class DialogMatterAddDevice extends LitElement {
hass: this.hass,
device: this._newDevice,
mainEntity: this._mainEntity,
proposedName: this._proposedDeviceName,
}
)}
</div>
@@ -36,6 +36,8 @@ class MatterAddDeviceDeviceAdded extends LitElement {
@property({ attribute: false }) public mainEntity?: ExtEntityRegistryEntry;
@property({ attribute: false }) public proposedName?: string;
@state() private _deviceName = "";
@state() private _area: string | undefined;
@@ -49,8 +51,18 @@ class MatterAddDeviceDeviceAdded extends LitElement {
protected willUpdate(changedProps: PropertyValues) {
if (!this._initialized && this.device) {
this._initialized = true;
this._deviceName = computeDeviceName(this.device) ?? "";
this._deviceName =
this.proposedName || (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 &&
@@ -158,7 +170,9 @@ class MatterAddDeviceDeviceAdded extends LitElement {
referrerpolicy="no-referrer"
/>
<div class="device-name">
<span>${computeDeviceName(this.device)}</span>
<span
>${this.proposedName || computeDeviceName(this.device)}</span
>
<span class="secondary">Matter</span>
</div>
</div>
@@ -0,0 +1,203 @@
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;
}
}
@@ -0,0 +1,27 @@
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);
}
`;
@@ -0,0 +1,387 @@
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;
}
}
@@ -1,252 +0,0 @@
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,3 +1,4 @@
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { Cluster, ZHADevice, ZHAGroup } from "../../../../../data/zha";
export const formatAsPaddedHex = (value: string | number): string => {
@@ -27,3 +28,16 @@ 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}`;
@@ -1,23 +0,0 @@
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,7 +3,6 @@ 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";
@@ -60,7 +59,7 @@ export class ZHAClusterAttributes extends LitElement {
return nothing;
}
return html`
<ha-card class="content">
<div class="content">
<div class="attribute-picker">
<ha-select
.label=${this.hass!.localize(
@@ -79,7 +78,7 @@ export class ZHAClusterAttributes extends LitElement {
${this._selectedAttributeId !== undefined
? this._renderAttributeInteractions()
: nothing}
</ha-card>
</div>
`;
}
@@ -222,8 +221,12 @@ export class ZHAClusterAttributes extends LitElement {
return [
haStyle,
css`
ha-card {
border: none;
:host {
display: block;
}
.content {
padding-top: var(--ha-space-4);
}
ha-select {
@@ -235,12 +238,7 @@ 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;
@@ -256,15 +254,12 @@ export class ZHAClusterAttributes extends LitElement {
padding-bottom: 10px;
}
.header {
flex-grow: 1;
}
.card-actions {
display: flex;
margin-top: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
padding: var(--ha-space-2);
justify-content: flex-end;
gap: var(--ha-space-3);
gap: var(--ha-space-2);
}
`,
];
@@ -2,7 +2,6 @@ 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";
@@ -54,7 +53,7 @@ export class ZHAClusterCommands extends LitElement {
return nothing;
}
return html`
<ha-card class="content">
<div class="content">
<div class="command-picker">
<ha-select
.label=${this.hass!.localize(
@@ -111,7 +110,7 @@ export class ZHAClusterCommands extends LitElement {
</div>
`
: ""}
</ha-card>
</div>
`;
}
@@ -184,8 +183,12 @@ export class ZHAClusterCommands extends LitElement {
return [
haStyle,
css`
ha-card {
border: none;
:host {
display: block;
}
.content {
padding-top: var(--ha-space-4);
}
ha-select {
@@ -196,12 +199,7 @@ 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;
@@ -225,24 +223,10 @@ 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,6 +35,10 @@ 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"),
@@ -62,7 +66,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.substr(1);
el.ieee = this.routeTail.path.split("/")[1];
} else if (this._currentPage === "visualization") {
el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1);
} else if (this._currentPage === "section") {
@@ -2,7 +2,6 @@ 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";
@@ -16,24 +15,29 @@ export class ZHADeviceBindingControl extends LitElement {
@property({ attribute: false }) public device?: ZHADevice;
@state() private _bindTargetIndex = -1;
@property({ attribute: false }) public bindableDevices: ZHADevice[] = [];
@state() private bindableDevices: ZHADevice[] = [];
@state() private _bindTargetIndex = -1;
@state() private _deviceToBind?: ZHADevice;
@state() private _bindingOperationInProgress = false;
protected updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("device")) {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged || changedProperties.has("bindableDevices")) {
this._bindTargetIndex = -1;
this._deviceToBind = undefined;
}
super.updated(changedProperties);
}
protected render(): TemplateResult {
return html`
<ha-card class="content">
<div class="content">
<div class="command-picker">
<ha-select
label=${this.hass!.localize(
@@ -69,7 +73,7 @@ export class ZHADeviceBindingControl extends LitElement {
${this.hass!.localize("ui.panel.config.zha.device_binding.bind")}
</ha-progress-button>
</div>
</ha-card>
</div>
`;
}
@@ -127,14 +131,11 @@ export class ZHADeviceBindingControl extends LitElement {
width: 100%;
}
.content {
padding: var(--ha-space-4) 0 0;
border: none;
outline: none;
:host {
display: block;
}
.command-picker {
align-items: center;
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
@@ -142,14 +143,12 @@ export class ZHADeviceBindingControl extends LitElement {
padding-bottom: 10px;
}
.header {
flex-grow: 1;
}
.card-actions {
display: flex;
margin-top: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
padding: var(--ha-space-2);
justify-content: flex-end;
gap: var(--ha-space-3);
gap: var(--ha-space-2);
}
`,
];
@@ -1,5 +1,6 @@
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../../../../../components/data-table/ha-data-table";
@@ -7,11 +8,14 @@ import type {
DataTableColumnContainer,
DataTableRowData,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-card";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-code-editor";
import { narrowViewportContext } from "../../../../../data/context";
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;
@@ -25,15 +29,25 @@ export interface DeviceRowData extends DataTableRowData {
class ZHADeviceNeighbors extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public device?: ZHADevice;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
private _narrow!: ContextType<typeof narrowViewportContext>;
@state() private _devices: Map<string, ZHADevice> | undefined;
@state() private _loaded = false;
@state() private _error?: string;
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (this.hass && changedProperties.has("device")) {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (this.hass && deviceChanged) {
this._fetchData();
}
}
@@ -47,15 +61,14 @@ class ZHADeviceNeighbors extends LitElement {
if (device && devices) {
device.neighbors.forEach((neighbor) => {
const zhaDevice: ZHADevice | undefined = devices.get(neighbor.ieee);
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,
});
}
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,
});
});
}
return outputDevices;
@@ -110,35 +123,128 @@ class ZHADeviceNeighbors extends LitElement {
}
);
protected render() {
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>`;
}
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`
${!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>`}
<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>
`;
}
private async _fetchData(): Promise<void> {
if (this.device && this.hass) {
const devices = await fetchDevices(this.hass!);
this._devices = new Map(
devices.map((device: ZHADevice) => [device.ieee, device])
);
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;
}
}
}
}
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 {
@@ -0,0 +1,342 @@
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,9 +1,12 @@
import type { PropertyValues } from "lit";
import type { CSSResultGroup, 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 {
@@ -35,10 +38,33 @@ class ZHADeviceZigbeeInfo extends LitElement {
}
return html`
<ha-code-editor mode="yaml" read-only .value=${this._signature} dir="ltr">
</ha-code-editor>
<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>
`;
}
static get styles(): CSSResultGroup {
return [haStyle, zhaDevicePageCardStyles];
}
}
declare global {
@@ -4,7 +4,6 @@ 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";
@@ -24,9 +23,9 @@ export class ZHAGroupBindingControl extends LitElement {
@property({ attribute: false }) public device?: ZHADevice;
@state() private _bindTargetIndex = -1;
@property({ attribute: false }) public groups: ZHAGroup[] = [];
@state() private groups: ZHAGroup[] = [];
@state() private _bindTargetIndex = -1;
@state() private _selectedClusters: string[] = [];
@@ -42,10 +41,17 @@ export class ZHAGroupBindingControl extends LitElement {
private _zhaClustersDataTable!: ZHAClustersDataTable;
protected updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("device")) {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged || changedProperties.has("groups")) {
this._bindTargetIndex = -1;
this._groupToBind = undefined;
this._selectedClusters = [];
this._clustersToBind = [];
}
if (deviceChanged) {
this._fetchClustersForZhaNode();
}
super.updated(changedProperties);
@@ -53,31 +59,31 @@ export class ZHAGroupBindingControl extends LitElement {
protected render(): TemplateResult {
return html`
<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">
<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-progress-button
@click=${this._onUnbindGroupClick}
.disabled=${!this._canBind || this._bindingOperationInProgress}
@@ -88,17 +94,16 @@ 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>
</ha-card>
</ha-config-section>
<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>
`;
}
@@ -171,10 +176,10 @@ export class ZHAGroupBindingControl extends LitElement {
}
private async _fetchClustersForZhaNode(): Promise<void> {
if (this.hass) {
if (this.hass && this.device) {
this._clusters = await fetchClustersForZhaDevice(
this.hass,
this.device!.ieee
this.device.ieee
);
this._clusters = this._clusters
.filter((cluster) => cluster.type.toLowerCase() === "out")
@@ -199,12 +204,11 @@ export class ZHAGroupBindingControl extends LitElement {
width: 100%;
}
.content {
padding-top: var(--ha-space-2);
:host {
display: block;
}
.command-picker {
align-items: center;
padding-left: 28px;
padding-right: 28px;
padding-inline-start: 28px;
@@ -212,22 +216,12 @@ 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-1);
gap: var(--ha-space-2);
}
`,
];
@@ -1,17 +1,21 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { mdiChevronDown } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, 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-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-tab-group";
import "../../../../../components/ha-tab-group-tab";
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 type { Cluster, ZHADevice } from "../../../../../data/zha";
import { fetchClustersForZhaDevice } from "../../../../../data/zha";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { computeClusterKey } from "./functions";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import { computeClusterSecondary, computeClusterValue } from "./functions";
import "./zha-cluster-attributes";
import "./zha-cluster-commands";
@@ -30,11 +34,9 @@ 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 _selectedClusterIndex = -1;
@state() private _selectedClusterValue?: string;
@state() private _clusters: Cluster[] = [];
@@ -44,6 +46,10 @@ 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) {
@@ -55,138 +61,307 @@ export class ZHAManageClusters extends LitElement {
}
protected updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("device")) {
const oldDevice = changedProperties.get("device");
const deviceChanged =
changedProperties.has("device") && this.device?.ieee !== oldDevice?.ieee;
if (deviceChanged) {
this._clusters = [];
this._selectedClusterIndex = -1;
this._selectedClusterValue = undefined;
this._selectedCluster = undefined;
this._clustersLoaded = false;
this._clustersError = undefined;
this._fetchClustersForZhaDevice();
}
super.updated(changedProperties);
}
protected render() {
if (!this.device || !this._clustersLoaded) {
if (!this.device) {
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="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>
<ha-card class="cluster-detail-card">
${this._renderClusterHeader()}
${this._selectedCluster
? html`
<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>
${this._renderClusterSegmentedTabs()}
${this._renderSelectedClusterPanel()}
`
: ""}
: 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._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];
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;
}
}
this._clustersLoaded = true;
}
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
private _clusterTabClicked(event: Event): void {
this._selectClusterTab(
(event.currentTarget as HTMLElement).dataset.tab as (typeof tabs)[number]
);
}
private _selectClusterTab(newTab: (typeof tabs)[number]): void {
if (newTab === this._currTab) {
return;
}
this._currTab = newTab;
}
private _selectedClusterChanged(event: HaSelectSelectEvent): void {
this._selectedClusterIndex = Number(event.detail.value);
this._selectedCluster = this._clusters[this._selectedClusterIndex];
private _selectedClusterChanged(event: ValueChangedEvent<string>): void {
this._selectClusterValue(event.detail.value);
}
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`
.content {
padding: var(--ha-space-4) 0 0;
border: none;
outline: none;
:host {
display: block;
}
ha-select {
margin-top: 16px;
}
.menu {
width: 100%;
}
.header {
flex-grow: 1;
}
.node-picker {
align-items: center;
margin: 0 var(--ha-space-5);
padding-bottom: 10px;
}
ha-tab-group {
margin: 0 var(--ha-space-5);
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
.loading-card,
.empty-card {
display: flex;
justify-content: center;
padding: var(--ha-space-8);
}
.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;
}
.cluster-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.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);
}
.cluster-tabs button.active {
border-bottom-color: var(--primary-color);
color: var(--primary-color);
}
`,
];
+118 -69
View File
@@ -35,6 +35,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../../types";
const IP_VERSIONS = ["ipv4", "ipv6"];
@@ -57,15 +58,15 @@ const PREDEFINED_DNS = {
};
@customElement("supervisor-network")
export class HassioNetwork extends LitElement {
export class HassioNetwork extends DirtyStateProviderMixin<NetworkInterface>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _accessPoints: AccessPoint[] = [];
@state() private _curTabIndex = 0;
@state() private _dirty = false;
@state() private _interface?: NetworkInterface;
@state() private _interfaces!: NetworkInterface[];
@@ -88,6 +89,7 @@ export class HassioNetwork extends LitElement {
a.primary > b.primary ? -1 : 1
);
this._interface = { ...this._interfaces[this._curTabIndex] };
this._initDirtyTracking({ type: "deep" }, this._interface);
}
protected render() {
@@ -230,7 +232,7 @@ export class HassioNetwork extends LitElement {
? this._renderIPConfiguration(version)
: nothing
)}
${this._dirty
${this.isDirtyState
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.supervisor.warning"
@@ -242,7 +244,7 @@ export class HassioNetwork extends LitElement {
<ha-button
.loading=${this._processing}
@click=${this._updateNetwork}
.disabled=${!this._dirty}
.disabled=${!this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -254,12 +256,17 @@ export class HassioNetwork extends LitElement {
private _selectAP(event) {
this._wifiConfiguration = event.currentTarget.ap;
let iface = this._interface!;
IP_VERSIONS.forEach((version) => {
if (this._interface![version]!.method === "disabled") {
this._interface![version]!.method = "auto";
if (iface[version]!.method === "disabled") {
iface = {
...iface,
[version]: { ...iface[version], method: "auto" },
};
}
});
this._dirty = true;
this._interface = iface;
this._updateDirtyState(this._interface);
}
private async _scanForAP() {
@@ -555,7 +562,7 @@ export class HassioNetwork extends LitElement {
this._interface!.interface,
interfaceOptions
);
this._dirty = false;
this._markDirtyStateClean();
await this._fetchNetworkInfo();
} catch (err: any) {
showAlertDialog(this, {
@@ -571,32 +578,38 @@ export class HassioNetwork extends LitElement {
private async _clear() {
await this._fetchNetworkInfo();
this._interface!.ipv4!.method = "auto";
this._interface!.ipv4!.nameservers = [];
this._interface!.ipv6!.method = "auto";
this._interface!.ipv6!.nameservers = [];
// removing the connection will disable the interface
// this is the only way to forget the wifi network right now
this._interface!.wifi = null;
this._interface = {
...this._interface!,
ipv4: {
...this._interface!.ipv4!,
method: "auto",
nameservers: [],
},
ipv6: {
...this._interface!.ipv6!,
method: "auto",
nameservers: [],
},
wifi: null,
};
this._wifiConfiguration = undefined;
this._dirty = true;
this.requestUpdate("_interface");
this._updateDirtyState(this._interface);
}
private async _handleTabActivated(ev: CustomEvent): Promise<void> {
if (this._dirty) {
if (this.isDirtyState) {
const confirm = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.network.supervisor.unsaved"),
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
});
if (!confirm) {
this.requestUpdate("_interface");
return;
}
}
this._curTabIndex = Number(ev.detail.name);
this._interface = { ...this._interfaces[this._curTabIndex] };
this._initDirtyTracking({ type: "deep" }, this._interface);
}
private _handleRadioValueChanged(ev: Event): void {
@@ -611,18 +624,19 @@ export class HassioNetwork extends LitElement {
) {
return;
}
this._dirty = true;
this._interface[version]!.method = value;
this.requestUpdate("_interface");
this._interface = {
...this._interface,
[version]: { ...this._interface[version], method: value },
};
this._updateDirtyState(this._interface);
}
private _handleRadioValueChangedAp(ev: Event): void {
const source = ev.currentTarget as HaRadioGroup;
const value = source.value as "open" | "wep" | "wpa-psk";
this._wifiConfiguration!.auth = value;
this._dirty = true;
this.requestUpdate("_wifiConfiguration");
this._wifiConfiguration = { ...this._wifiConfiguration!, auth: value };
this._updateDirtyState(this._interface!);
}
private _handleInputValueChanged(ev: Event): void {
@@ -636,35 +650,50 @@ export class HassioNetwork extends LitElement {
return;
}
this._dirty = true;
const versionData = this._interface[version];
if (id === "address") {
const index = (ev.target as any).index as number;
const { mask: oldMask } = parseAddress(
this._interface[version].address![index]
);
const { mask: oldMask } = parseAddress(versionData.address![index]);
const { mask } = parseAddress(value);
this._interface[version].address![index] = formatAddress(
value,
mask || oldMask || ""
);
this.requestUpdate("_interface");
const newAddress = [...versionData.address!];
newAddress[index] = formatAddress(value, mask || oldMask || "");
this._interface = {
...this._interface,
[version]: { ...versionData, address: newAddress },
};
} else if (id === "netmask") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = formatAddress(ip, value);
this.requestUpdate("_interface");
const { ip } = parseAddress(versionData.address![index]);
const newAddress = [...versionData.address!];
newAddress[index] = formatAddress(ip, value);
this._interface = {
...this._interface,
[version]: { ...versionData, address: newAddress },
};
} else if (id === "prefix") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = `${ip}/${value}`;
this.requestUpdate("_interface");
const { ip } = parseAddress(versionData.address![index]);
const newAddress = [...versionData.address!];
newAddress[index] = `${ip}/${value}`;
this._interface = {
...this._interface,
[version]: { ...versionData, address: newAddress },
};
} else if (id === "nameserver") {
const index = (ev.target as any).index as number;
this._interface[version].nameservers![index] = value;
this.requestUpdate("_interface");
const newNameservers = [...versionData.nameservers!];
newNameservers[index] = value;
this._interface = {
...this._interface,
[version]: { ...versionData, nameservers: newNameservers },
};
} else {
this._interface[version][id] = value;
this._interface = {
...this._interface,
[version]: { ...versionData, [id]: value },
};
}
this._updateDirtyState(this._interface);
}
private _handleInputValueChangedWifi(ev: Event): void {
@@ -680,26 +709,35 @@ export class HassioNetwork extends LitElement {
source.reportValidity();
return;
}
this._dirty = true;
this._wifiConfiguration![id] = value;
this._wifiConfiguration = { ...this._wifiConfiguration, [id]: value };
this._updateDirtyState(this._interface!);
}
private _addAddress(ev: Event): void {
const version = (ev.target as any).version as "ipv4" | "ipv6";
this._interface![version]!.address!.push(
version === "ipv4" ? "0.0.0.0/24" : "::/64"
);
this._dirty = true;
this.requestUpdate("_interface");
const newAddr = version === "ipv4" ? "0.0.0.0/24" : "::/64";
this._interface = {
...this._interface!,
[version]: {
...this._interface![version],
address: [...this._interface![version]!.address!, newAddr],
},
};
this._updateDirtyState(this._interface);
}
private _removeAddress(ev: Event): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
this._interface![version]!.address!.splice(index, 1);
this._dirty = true;
this.requestUpdate("_interface");
const newAddress = this._interface![version]!.address!.filter(
(_, i) => i !== index
);
this._interface = {
...this._interface!,
[version]: { ...this._interface![version], address: newAddress },
};
this._updateDirtyState(this._interface);
}
private _handleDNSMenuOpened() {
@@ -711,30 +749,41 @@ export class HassioNetwork extends LitElement {
}
private _addPredefinedDNS(version: "ipv4" | "ipv6", addresses: string[]) {
if (!this._interface![version]!.nameservers) {
this._interface![version]!.nameservers = [];
}
this._interface![version]!.nameservers!.push(...addresses);
this._dirty = true;
this.requestUpdate("_interface");
const existing = this._interface![version]!.nameservers || [];
this._interface = {
...this._interface!,
[version]: {
...this._interface![version],
nameservers: [...existing, ...addresses],
},
};
this._updateDirtyState(this._interface);
}
private _addCustomDNS(version: "ipv4" | "ipv6") {
if (!this._interface![version]!.nameservers) {
this._interface![version]!.nameservers = [];
}
this._interface![version]!.nameservers!.push("");
this._dirty = true;
this.requestUpdate("_interface");
const existing = this._interface![version]!.nameservers || [];
this._interface = {
...this._interface!,
[version]: {
...this._interface![version],
nameservers: [...existing, ""],
},
};
this._updateDirtyState(this._interface);
}
private _removeNameserver(ev: Event): void {
const source = ev.target as any;
const index = source.index as number;
const version = source.version as "ipv4" | "ipv6";
this._interface![version]!.nameservers!.splice(index, 1);
this._dirty = true;
this.requestUpdate("_interface");
const newNameservers = this._interface![version]!.nameservers!.filter(
(_, i) => i !== index
);
this._interface = {
...this._interface!,
[version]: { ...this._interface![version], nameservers: newNameservers },
};
this._updateDirtyState(this._interface);
}
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
+61 -7
View File
@@ -25,6 +25,7 @@ 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";
@@ -47,7 +48,11 @@ import {
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
entityRegistryByEntityId,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { domainToName } from "../../../data/integration";
import type {
SceneConfig,
SceneEntities,
@@ -71,6 +76,7 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@@ -92,8 +98,8 @@ interface DeviceEntities {
type DeviceEntitiesLookup = Record<string, string[]>;
@customElement("ha-scene-editor")
export class HaSceneEditor extends PreventUnsavedMixin(
KeyboardShortcutMixin(LitElement)
export class HaSceneEditor extends DirtyStateProviderMixin<boolean>()(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -206,6 +212,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
public connectedCallback() {
super.connectedCallback();
this._initDirtyTracking({ type: "shallow" }, false);
if (!this.sceneId) {
this._mode = "live";
this._subscribeEvents();
@@ -344,6 +351,9 @@ export class HaSceneEditor extends PreventUnsavedMixin(
this._deviceEntityLookup,
Object.values(this.hass.devices)
);
const entityRegistryLookup = entityRegistryByEntityId(
this._entityRegistryEntries
);
return html` <div
id="root"
class=${classMap({
@@ -423,9 +433,19 @@ 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}
@@ -445,6 +465,14 @@ 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>
`;
})}
@@ -496,10 +524,19 @@ 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}
@@ -517,7 +554,13 @@ 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}
@@ -562,6 +605,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_dirty")) {
this._updateDirtyState(this._dirty);
}
if (
this._entityRegCreated &&
this._newSceneId &&
@@ -1281,10 +1328,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
});
}
protected get isDirty() {
return this._dirty;
}
protected async promptDiscardChanges() {
return this._confirmUnsavedChanged();
}
@@ -1355,10 +1398,21 @@ 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;
}
`,
];
}
+24 -68
View File
@@ -13,13 +13,16 @@ 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 {
createSearchParam,
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
@@ -230,43 +233,18 @@ class HaPanelHistory extends LitElement {
return;
}
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 = {};
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
}
if (entityIds) {
const splitIds = entityIds.split(",");
this._targetPickerValue!.entity_id = splitIds;
if (queryParams.start_date) {
this._startDate = queryParams.start_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);
if (queryParams.end_date) {
this._endDate = queryParams.end_date;
}
}
@@ -469,37 +447,15 @@ class HaPanelHistory extends LitElement {
}
private _updatePath() {
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 });
navigate(
createHistoryLogbookUrl(
"/history",
this._targetPickerValue,
this._startDate,
this._endDate
),
{ replace: true }
);
}
private async _handleMenuAction(ev: HaDropdownSelectEvent) {
+25 -71
View File
@@ -4,12 +4,15 @@ 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 {
createSearchParam,
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
@@ -185,44 +188,17 @@ export class HaPanelLogbook extends LitElement {
);
private _applyURLParams() {
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;
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
}
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];
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];
// Only set if date has changed.
if (
@@ -231,8 +207,8 @@ export class HaPanelLogbook extends LitElement {
) {
this._time = {
range: [
startDateStr ? new Date(startDateStr) : this._time.range[0],
endDateStr ? new Date(endDateStr) : this._time.range[1],
queryParams.start_date ?? this._time.range[0],
queryParams.end_date ?? this._time.range[1],
],
};
}
@@ -254,37 +230,15 @@ export class HaPanelLogbook extends LitElement {
}
private _updatePath() {
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 });
navigate(
createHistoryLogbookUrl(
"/logbook",
this._targetPickerValue,
this._time.range[0],
this._time.range[1]
),
{ replace: true }
);
}
private _refreshLogbook() {
@@ -11,8 +11,10 @@ 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";
@@ -109,6 +111,16 @@ 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" : ""}
@@ -122,7 +134,7 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
</div>
</div>
<div class="content">
${this._computeAttributes(stateObj).map(
${attributes.map(
(item) => html`
<div
class="attributes"
@@ -134,6 +146,7 @@ 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,6 +247,11 @@ 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;
@@ -81,6 +81,7 @@ export class HuiEnergyGraphCardEditor
? [
{
name: "show_legend",
default: true,
required: false,
selector: { boolean: {} },
},
@@ -616,6 +616,10 @@ 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);
}
`,
];
}
+21 -11
View File
@@ -17,6 +17,7 @@ import {
showAlertDialog,
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 type { Lovelace } from "./types";
@@ -33,7 +34,9 @@ const strategyStruct = type({
});
@customElement("hui-editor")
class LovelaceFullConfigEditor extends LitElement {
class LovelaceFullConfigEditor extends DirtyStateProviderMixin<boolean>()(
LitElement
) {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -44,8 +47,6 @@ class LovelaceFullConfigEditor extends LitElement {
@state() private _saving?: boolean;
@state() private _changed?: boolean;
private _config?: LovelaceRawConfig;
private _yamlError?: string;
@@ -66,10 +67,10 @@ class LovelaceFullConfigEditor extends LitElement {
slot="actionItems"
class="save-button
${classMap({
saved: this._saving === false || this._changed === true,
saved: this._saving === false || this.isDirtyState,
})}"
>
${this._changed
${this.isDirtyState
? this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.unsaved_changes"
)
@@ -78,7 +79,7 @@ class LovelaceFullConfigEditor extends LitElement {
<ha-button
slot="actionItems"
@click=${this._handleSave}
.disabled=${!this._changed}
.disabled=${!this.isDirtyState}
>${this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.save"
)}</ha-button
@@ -96,6 +97,14 @@ class LovelaceFullConfigEditor extends LitElement {
`;
}
connectedCallback(): void {
super.connectedCallback();
this._initDirtyTracking(
{ type: "custom", compare: (a, b) => a === b },
false
);
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.yamlEditor.setValue(this.lovelace!.rawConfig);
@@ -158,17 +167,18 @@ class LovelaceFullConfigEditor extends LitElement {
private _yamlChanged(ev: CustomEvent) {
this._config = ev.detail.isValid ? ev.detail.value : undefined;
this._yamlError = ev.detail.errorMsg;
this._changed = undoDepth(this.yamlEditor.codemirror!.state) > 0;
if (this._changed && !window.onbeforeunload) {
const changed = undoDepth(this.yamlEditor.codemirror!.state) > 0;
this._updateDirtyState(changed);
if (changed && !window.onbeforeunload) {
window.onbeforeunload = () => true;
} else if (!this._changed && window.onbeforeunload) {
} else if (!changed && window.onbeforeunload) {
window.onbeforeunload = null;
}
}
private async _closeEditor() {
if (
this._changed &&
this.isDirtyState &&
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.lovelace.editor.raw_editor.confirm_unsaved_changes"
@@ -279,7 +289,7 @@ class LovelaceFullConfigEditor extends LitElement {
});
}
window.onbeforeunload = null;
this._changed = false;
this._markDirtyStateClean();
this._saving = false;
}
+7
View File
@@ -1351,6 +1351,13 @@ 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,5 +1,6 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
@@ -14,6 +15,10 @@ 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";
@@ -33,6 +38,16 @@ 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[]
@@ -42,6 +57,19 @@ 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;
});
+40 -203
View File
@@ -1,34 +1,19 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { normalizeLuminance } from "../../common/color/palette";
import { fireEvent } from "../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-settings-row";
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 "../../components/ha-theme-settings";
import {
saveThemePreferences,
subscribeThemePreferences,
} from "../../data/theme";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
DefaultAccentColor,
DefaultPrimaryColor,
} from "../../resources/theme/color/color.globals";
import type {
HomeAssistant,
ThemeSettings,
ValueChangedEvent,
} from "../../types";
import type { HomeAssistant, ThemeSettings } 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;
@@ -54,14 +39,6 @@ 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 &&
@@ -69,14 +46,15 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
localTheme !== null;
return html`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading"
>${this.hass.localize("ui.panel.profile.themes.header")}</span
>
<span slot="description">
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.hass.selectedTheme}
.narrow=${this.narrow}
.heading=${this.hass.localize("ui.panel.profile.themes.header")}
.description=${html`
${!hasThemes
? this.hass.localize("ui.panel.profile.themes.error_no_theme")
: ""}
: nothing}
<a
href=${documentationUrl(
this.hass,
@@ -87,81 +65,32 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
>
${this.hass.localize("ui.panel.profile.themes.link_promo")}
</a>
</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>`
: ""}
`}
.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>
${showMigration
? html`
<ha-settings-row .narrow=${this.narrow}>
@@ -183,77 +112,12 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
</ha-button>
</ha-settings-row>
`
: ""}
: nothing}
`;
}
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 _themeSettingsChanged(ev: HASSDomEvent<Partial<ThemeSettings>>) {
fireEvent(this, "settheme", ev.detail);
}
private _getLocalTheme(): ThemeSettings | null {
@@ -285,33 +149,6 @@ 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%;
}
`;
}
+32 -12
View File
@@ -1197,6 +1197,7 @@
"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",
@@ -2254,15 +2255,6 @@
"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",
@@ -4389,7 +4381,9 @@
"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"
"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."
},
"dialog": {
"create": "Create",
@@ -7334,10 +7328,32 @@
"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",
"help_cluster_dropdown": "Select a cluster to view attributes and commands.",
"change_cluster": "Change cluster",
"cluster_description": "Endpoint ID: {endpoint} · ID: {id} · Type: {type}",
"load_failed": "Failed to load clusters",
"no_clusters": "No clusters found",
"tabs": {
"attributes": "Attributes",
"commands": "Commands"
@@ -7414,6 +7430,8 @@
"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"
@@ -7433,7 +7451,9 @@
"name": "Name",
"lqi": "LQI",
"relationship": "Relationship",
"depth": "Depth"
"depth": "Depth",
"load_failed": "Failed to load neighbors",
"no_neighbors": "No neighbors found"
},
"change_channel_dialog": {
"title": "Change Zigbee channel",
-23
View File
@@ -2,7 +2,6 @@ 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";
@@ -50,28 +49,6 @@ 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
@@ -0,0 +1,146 @@
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.2, @codemirror/autocomplete@npm:^6.0.0":
version: 6.20.2
resolution: "@codemirror/autocomplete@npm:6.20.2"
"@codemirror/autocomplete@npm:6.20.3, @codemirror/autocomplete@npm:^6.0.0":
version: 6.20.3
resolution: "@codemirror/autocomplete@npm:6.20.3"
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/ee0612e55a52b55262d897972741f1c54c92317b34735943c73cf1e8608fd3b21604b0f2fca0cbe67b9bddb3b673777023a45237086c1296f8f5103e0ad01219
checksum: 10/1e66faedba3c7c1520b938838de2a95d4d4f16d168158e8dfd4d34bb72fe9538f4d9e73449bd2c8bba6f1f0a40f4a754e656637296d434216529c6426828aaf5
languageName: node
linkType: hard
@@ -1646,135 +1646,135 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/bigdecimal@npm:0.2.5":
version: 0.2.5
resolution: "@formatjs/bigdecimal@npm:0.2.5"
checksum: 10/035a70be4175d47d82d81025ad4386d7c248ef7afb2676b4e0773595d01df1078f3d5224e2f7f17721c9169bbb77d4282898624bf0112d2e6350438b7032a1cb
"@formatjs/bigdecimal@npm:0.2.6":
version: 0.2.6
resolution: "@formatjs/bigdecimal@npm:0.2.6"
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.5":
version: 3.1.5
resolution: "@formatjs/fast-memoize@npm:3.1.5"
checksum: 10/5ff47e3cc5b46a72028cf0c6a3a291781cc3c7e198b6d4446119d870d6366ecc5a05d5826fce3568bab62abf9f7b4ebbe9bfb8420bb81bbe2a8a120b062ac909
"@formatjs/fast-memoize@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/fast-memoize@npm:3.1.6"
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.10":
version: 3.5.10
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.10"
"@formatjs/icu-messageformat-parser@npm:3.5.11":
version: 3.5.11
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.11"
dependencies:
"@formatjs/icu-skeleton-parser": "npm:2.1.9"
checksum: 10/44392248b9247cf83a21b43c749025bfbc23acd63782b9a1b7dc47bf5520b686f8a5dccfa56716bc81fe0680000029aba22f5eb5c821ec529646758bd2d6af79
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
checksum: 10/59b8323b9615edb4ca6463d09108581469de7566f2aec106f1d87b259333cdbf9577d9c22a990bc454f3f461c799f4add683a9ff7185af6fa225fcd29ec336da
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.9":
version: 2.1.9
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.9"
checksum: 10/eacb8acd60d487092fc1a6b7fdbac87dfc32475db7001562034a8ca7b0a4be7a35f95c30928ae8314f5e680f63302180bece9462c872a042a0302a5f4cf6a842
"@formatjs/icu-skeleton-parser@npm:2.1.10":
version: 2.1.10
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.4.7":
version: 7.4.7
resolution: "@formatjs/intl-datetimeformat@npm:7.4.7"
"@formatjs/intl-datetimeformat@npm:7.4.8":
version: 7.4.8
resolution: "@formatjs/intl-datetimeformat@npm:7.4.8"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/ab3a1806aede409f2eef59abd9657c0281de5b999dc992111fdc0c3d3e7fe3934e951abb934f469514111c262200985963e24a8d6b7bce1945f6d36dbd64f06f
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/9dde6796f1e260fbb486f27b1a5774a70aef2b4259b102b745b495d93ea5881f0df80d133bf92138cb003c77b7a016f125562f20360a92125680cc7f54621971
languageName: node
linkType: hard
"@formatjs/intl-displaynames@npm:7.3.9":
version: 7.3.9
resolution: "@formatjs/intl-displaynames@npm:7.3.9"
"@formatjs/intl-displaynames@npm:7.3.10":
version: 7.3.10
resolution: "@formatjs/intl-displaynames@npm:7.3.10"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/c5c6295be1e89b1ae497aab1df166714580499b67eda86e6504e54b29d434482633ae050c57d6fc06fd7d33b2efc5bf0405ff90933c328ec1f8924f1e349db55
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/da8809106f59e42c80d71f64d6965cb0c57daf5309976dedf2516ef1bd57912d46a5076f67f396406056425f42d415d10c6ddf9ed760971b484ac799462eb147
languageName: node
linkType: hard
"@formatjs/intl-durationformat@npm:0.10.13":
version: 0.10.13
resolution: "@formatjs/intl-durationformat@npm:0.10.13"
"@formatjs/intl-durationformat@npm:0.10.14":
version: 0.10.14
resolution: "@formatjs/intl-durationformat@npm:0.10.14"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/ae33f714dccd8a0be95b216e07fabfaf44de6dfb1a4de51a266a0f55e6fa510ce77a8da461ed106549ba5cb7f0bf35e3dc019b5e03f57388d9d5b1b8867e2b6f
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/0ca4898860322330442e150cde351924401f95aa772de276662c97e0c3e1dff619cac2054b608ce171d2235a4f215639deebd480eb63f81d747c70e9cd191c01
languageName: node
linkType: hard
"@formatjs/intl-getcanonicallocales@npm:3.2.9":
version: 3.2.9
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.9"
checksum: 10/818cf303c5fbc5607af6efc57720eabde1ea16eca4f58dde1f3f6f5ec21e7697d300e59dc61769c928ff9fc53b26ea510e5255e1d263f92f48e3baf43c8f984a
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
version: 3.2.10
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
languageName: node
linkType: hard
"@formatjs/intl-listformat@npm:8.3.9":
version: 8.3.9
resolution: "@formatjs/intl-listformat@npm:8.3.9"
"@formatjs/intl-listformat@npm:8.3.10":
version: 8.3.10
resolution: "@formatjs/intl-listformat@npm:8.3.10"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/36bee2f177d3d705ba9b95133b35404263fe08c15f781978d12b2a349d6e570b4ffdfae63a7a9ef233dfe8067e26584cadf6f4e252cd482c1b3a045dcca6bc52
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/ebfc78061842ff45cfadab9e1676944093e421b04f21da16925b2ed04a71cdf8213cfc442f01e537d0de1255e745d03b74b935525d90c0df9f979627b1a70d97
languageName: node
linkType: hard
"@formatjs/intl-locale@npm:5.3.8":
version: 5.3.8
resolution: "@formatjs/intl-locale@npm:5.3.8"
"@formatjs/intl-locale@npm:5.3.9":
version: 5.3.9
resolution: "@formatjs/intl-locale@npm:5.3.9"
dependencies:
"@formatjs/intl-getcanonicallocales": "npm:3.2.9"
"@formatjs/intl-supportedvaluesof": "npm:2.3.7"
checksum: 10/86881fe5142b21f976e92c90ed34ac591ef4f470334f2d3f17f8a4784d64ba51d5a2a35f45e40da02a02227d2cc63dae2f20a0365299b0a6e44c5613f8777a92
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.9":
version: 0.8.9
resolution: "@formatjs/intl-localematcher@npm:0.8.9"
"@formatjs/intl-localematcher@npm:0.8.10":
version: 0.8.10
resolution: "@formatjs/intl-localematcher@npm:0.8.10"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.5"
checksum: 10/05873efaae86a72b738dc1ce6296d975a74f27f90cd5dd4a27e704acd2389e58f5db84f4735e6b1043723cc65ab5636fb345ec88d1cf9a1dafad57180e4d06b0
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/d9d3f408363091bf35950a842f58c662d88d9f54d9a53b1238cb673a73d1345412d78e01a2d56ec58cef81b9c66a8ce0ee78ed9c31976bd56dc68a38ef3cbc0f
languageName: node
linkType: hard
"@formatjs/intl-numberformat@npm:9.3.10":
version: 9.3.10
resolution: "@formatjs/intl-numberformat@npm:9.3.10"
"@formatjs/intl-numberformat@npm:9.3.11":
version: 9.3.11
resolution: "@formatjs/intl-numberformat@npm:9.3.11"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/ef954ca4b60e46d98ee41514352a8977c8d9cf2ca1df684178f3571ecafa7d4c2a0e7270e45c9ccf54b2f1f43e4560a907fb06d9a70a7920fab5f239fe3a6205
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/ef8221c37c0611911b06c9a94d02e9d334048c0f2c8e5f049804f067afb628677e72215a9729a742f6b701b12d95d443b2e58a33981af6b65417b26f69d0d038
languageName: node
linkType: hard
"@formatjs/intl-pluralrules@npm:6.3.9":
version: 6.3.9
resolution: "@formatjs/intl-pluralrules@npm:6.3.9"
"@formatjs/intl-pluralrules@npm:6.3.10":
version: 6.3.10
resolution: "@formatjs/intl-pluralrules@npm:6.3.10"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.5"
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/c35cc1594596d3df557a683c89d5be18ee02150d2f9d2ba946e457e4ad2067e7f4d482487da1855f8f36ff83a773cef3f8aeee8c80ae7660d8c6eb6fe28f5985
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/88ade192dca87d3efa3367e68efb703495a8fbcca9bd30b0aea62286134407dfffada0adb5125618c157eacd83575ffaf890db478fabd82c3f67f1ea0b5472ab
languageName: node
linkType: hard
"@formatjs/intl-relativetimeformat@npm:12.3.9":
version: 12.3.9
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.9"
"@formatjs/intl-relativetimeformat@npm:12.3.10":
version: 12.3.10
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.10"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.9"
checksum: 10/cbeaa2d801f09ca6aad66aa15e1827d93997410029fbd1f7e55ae0a1cf56cde1973c8ba724afc0da349e2d30a670b1ec5f207d89f7aa502628d4c3f90a0a5b3c
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/f94f642d112ddaf0dd922c1887bf53d174bafed2aeb10d6305933cb9cb5fc7929e894516aa2d795fd4f5bfe5074330198e4dc3652a413d3e8bf3a272abcff7ea
languageName: node
linkType: hard
"@formatjs/intl-supportedvaluesof@npm:2.3.7":
version: 2.3.7
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.7"
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
version: 2.3.8
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.5"
checksum: 10/89695ea2c2af7b0abbe5e2fe9b1c96047e61bfcceef57749b66693cf01bff66943fbefbdbaec5ec1015f003d2af500a81bae531d2f9a60d423e883532133be01
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
languageName: node
linkType: hard
@@ -4041,161 +4041,161 @@ __metadata:
languageName: node
linkType: hard
"@tsparticles/basic@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/basic@npm:4.1.2"
"@tsparticles/basic@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/basic@npm:4.1.3"
dependencies:
"@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
"@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
languageName: node
linkType: hard
"@tsparticles/canvas-utils@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/canvas-utils@npm:4.1.2"
"@tsparticles/canvas-utils@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/canvas-utils@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/ffedc8400b5ff758331bdf1ef2362aac713c3e41d5bcece00153acc04c725947c41d545202d73bd6ef290d2ec220353f1801fb4ad22e99870989b0c68d7540c4
"@tsparticles/engine": 4.1.3
checksum: 10/e69c6f9f49a32f4aa8d1e4444e93390615dddf95d62c2b14bfe53a113d417e5ffaf0cc0a8336456a6f1a6c559459454d6829d629e4ef28df9643c9ec12b9dbd0
languageName: node
linkType: hard
"@tsparticles/engine@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/engine@npm:4.1.2"
checksum: 10/6fe6aa50bba564a8a8691945cb378267e695ec58323066a4157e8a036e7a3caa99e1ced08d05bffd173045bbc33c4d41523afc28624b1bea015393c1a88ef2bb
"@tsparticles/engine@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/engine@npm:4.1.3"
checksum: 10/6506f055b044deb13fd910ec19fa9ebba7ac3cab8cd6d67ec5137e0a05ed88ff6a40514c62056c2f6c86882062ad42a9af440a24dede08fff6a3b0f270b1eb3c
languageName: node
linkType: hard
"@tsparticles/interaction-particles-links@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/interaction-particles-links@npm:4.1.2"
"@tsparticles/interaction-particles-links@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/interaction-particles-links@npm:4.1.3"
dependencies:
"@tsparticles/canvas-utils": "npm:4.1.2"
"@tsparticles/canvas-utils": "npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
"@tsparticles/plugin-interactivity": 4.1.2
checksum: 10/b9e1e85bdb3226a25d7a4486556abfe458c81c109855c04857623d2d3506ef325fcc2dc4fcee962414e216aa6346bc0849df1bf7199a3e4e75bed2fb7f069702
"@tsparticles/engine": 4.1.3
"@tsparticles/plugin-interactivity": 4.1.3
checksum: 10/770b77ef1f2e6caee5e3c364a40a3a184d93f2989f2ade7b83db8a043d2ffeb1ac5328c7fad8a9c00cc362bc467ccac215282c22c0fd9cd30005ad12393cabab
languageName: node
linkType: hard
"@tsparticles/plugin-blend@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-blend@npm:4.1.2"
"@tsparticles/plugin-blend@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-blend@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/1f51bcf76d6d749a0a18799fd3a814a82e62f060fd3c20d28f8bb170311815aeba8efc361e73cef7356c51151c7debafc75d34bff6ab2d4e58099b51774a28c6
"@tsparticles/engine": 4.1.3
checksum: 10/418a37e49dccf8c680953715be358ffe381a5198a7b683fe445d1052468fac9e4cc5b4026d43b1e9b9c43f341b27a7c1ce84d7e8fe24348580b9c07aa645951f
languageName: node
linkType: hard
"@tsparticles/plugin-hex-color@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-hex-color@npm:4.1.2"
"@tsparticles/plugin-hex-color@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-hex-color@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/d32a39bbd6732b9e630f84a7d226753735f02d65b70a1d5d11fa680421d5f1378aee75af1ecb5ada5af9875822bbaf2b5d1f213d1e0cbce2a50820743eb9c2fc
"@tsparticles/engine": 4.1.3
checksum: 10/782cef17a059ffeba447c0f797d332b24bf91088c783f952f7d3d4f1c79c4ff95c35be404db952c9e03a091a3520a603638f20e9313d64014d41d41d7d2ab0fe
languageName: node
linkType: hard
"@tsparticles/plugin-hsl-color@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-hsl-color@npm:4.1.2"
"@tsparticles/plugin-hsl-color@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-hsl-color@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/c130e80b0fd2750402fa0e309d0bb624b2138474069ae4ccb76610722c4f0966f278ad1a35c85fbcf5449914936f1c53322246f1cdd028106d83f779beee538c
"@tsparticles/engine": 4.1.3
checksum: 10/0c53b7143288dd4e5f39525bac96c2bff70b0da32b49fc11be569077bc8c87b204bf541a9e4cbb007d0e697f00957fe8ca974ee39eddead355455f1e8719fe56
languageName: node
linkType: hard
"@tsparticles/plugin-interactivity@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-interactivity@npm:4.1.2"
"@tsparticles/plugin-interactivity@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-interactivity@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/78efd4ffe3a07752f26262fe27ee0a931b45fea6c81d62e156d8f965dc22a2141d64395eb99477624ca9aac9e152f9e2585fc8aced9c822c61cb22ecfad184a4
"@tsparticles/engine": 4.1.3
checksum: 10/286a1348d474a58f60151922e78e34dd7a3507b7c97f86490e09e80bc762539f02e7ee8c693536aafbd83beaf8a3b6708bab05941e1f82ab840f2d9325f77e47
languageName: node
linkType: hard
"@tsparticles/plugin-move@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-move@npm:4.1.2"
"@tsparticles/plugin-move@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-move@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/fac5b90904d3fa62b310a1ce19647c5ae4a592111b5eeccfc6e946c6d17d677b0f3931ba3c114f04045827a92c1a0ed66791e99b352cb75d6c50f3749f389bfc
"@tsparticles/engine": 4.1.3
checksum: 10/fe7ccb2d060ca80858676b2baea7f1a75f7ef7b794cae7e73044467c9d931ceed712aa78ebf768ac57c4a34b9997f4b508bf4b03e152fce209b6feba458b3aca
languageName: node
linkType: hard
"@tsparticles/plugin-rgb-color@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/plugin-rgb-color@npm:4.1.2"
"@tsparticles/plugin-rgb-color@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/plugin-rgb-color@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/7e3e83171f74f7e9e3f68ce8d0aeb4685b277ecac3c4874a951e86228d3db78978d6197ef9e04b17190471294323c561c18c92c9d9ae8e8a115e6238cbecdb43
"@tsparticles/engine": 4.1.3
checksum: 10/ee32e1ce0f8670090c1dc4a1f89ecdc13a0d6a78c8deedfe925405ebebdecc386cc92cea71110259e3adc5d12f3b181e2125abdfefef791f86cf2f8bc8df0836
languageName: node
linkType: hard
"@tsparticles/preset-links@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/preset-links@npm:4.1.2"
"@tsparticles/preset-links@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/preset-links@npm:4.1.3"
dependencies:
"@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
"@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
languageName: node
linkType: hard
"@tsparticles/shape-circle@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/shape-circle@npm:4.1.2"
"@tsparticles/shape-circle@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/shape-circle@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/60a87755d4e598c278c1850b58b07bcfbef603de242de3979702cd8120cf24fd95d985b2092c7a8a2cccd43eb6a1939ab215f74a93898ea1e2d425be5deb405c
"@tsparticles/engine": 4.1.3
checksum: 10/da8800a93096774d915989fc55499c0fc6775257a4d3b3141edb7d5cd5bf7354f4183d067041d094eba9e6ed1cfda79f1dd7ea51a06b3383490f1ed3b0513b61
languageName: node
linkType: hard
"@tsparticles/updater-opacity@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-opacity@npm:4.1.2"
"@tsparticles/updater-opacity@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-opacity@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/4dc426175bb3e4c5d3bc35e7d83ace7130c4f8817c7f88ee6bf1b4b1a52b28511d99b1fc7ded3c549a5baae401f7c22cc701d83919694387154aef86ea6974c2
"@tsparticles/engine": 4.1.3
checksum: 10/298ad4ef6fd9043d15e3ab5704d405e81cc3ed95638e480991b8eb44fae8dc459c96e80c730b436d035f78dcc16d66a07554c25752982e7b231dedfd8801bdd2
languageName: node
linkType: hard
"@tsparticles/updater-out-modes@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-out-modes@npm:4.1.2"
"@tsparticles/updater-out-modes@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-out-modes@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/901be7b7dfa97d3d4ff857715a6b61dd4bc2ca09f23fee38d86cd6827351134c5cd4c3fa702d2aec8601544b1f6935f2b4034c4567a00128f9cf36cbaf379722
"@tsparticles/engine": 4.1.3
checksum: 10/14a9aa5262dadcd2cc557936e24e64b5e4df57f96d3b327f8d195ed509b8d94f6fc07e3f9d7f3add67d665df9a9ecb9642704dc97455fd5c51475efdbdca9b71
languageName: node
linkType: hard
"@tsparticles/updater-paint@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-paint@npm:4.1.2"
"@tsparticles/updater-paint@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-paint@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/5a0b4bc0a4c6061e1eb93a6af7a97b472e5d552677dafe8c888b0f9a71b68ed10ef255d86f2cf8d8466ecd1d33ba8f5e52920947237fe1218cbcd002c50dff47
"@tsparticles/engine": 4.1.3
checksum: 10/8e29683068e57deedbea1d902b582e4b0457b284e62dbcf77c85330ab52b13bb08fef09f0c6aa3e77533f22e763bb7b4369069985436a6421569a3cdd62b3f57
languageName: node
linkType: hard
"@tsparticles/updater-size@npm:4.1.2":
version: 4.1.2
resolution: "@tsparticles/updater-size@npm:4.1.2"
"@tsparticles/updater-size@npm:4.1.3":
version: 4.1.3
resolution: "@tsparticles/updater-size@npm:4.1.3"
peerDependencies:
"@tsparticles/engine": 4.1.2
checksum: 10/d5c4f79d5c1d3d977dd5ebac56c9679df134ddba919b8e2ee1473820f2c551f5b162356b49b22e6e54134b87a27c51cb5a6c2e2441ba2569b0dab62974e91a47
"@tsparticles/engine": 4.1.3
checksum: 10/ec4376facea1ca6ab79adf76237975ce7762d89ed58f31ed52f0061f23c5e12e04a60786898e65fcfa323b33f21d0278d6918ff3cf225fb9817f76c9f8b93e79
languageName: node
linkType: hard
@@ -7957,10 +7957,10 @@ __metadata:
languageName: node
linkType: hard
"fuse.js@npm:7.4.1":
version: 7.4.1
resolution: "fuse.js@npm:7.4.1"
checksum: 10/581941d5015968ee624feb10a56d9b49d5d954672b2c9ec189d4ca513da6f8a3dea2d5f6637386d8298ffc5846f6d83435210d40a47c58e14d11dc5707544c75
"fuse.js@npm:7.4.2":
version: 7.4.2
resolution: "fuse.js@npm:7.4.2"
checksum: 10/1605bb929331056f9215a8f0a19b2d22615d9195e17794f41254bdeeb77b0145ed0ad6b6842227b329783a4ab53586d795924e7a4a3fb664295f365ef6e2405c
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.2"
"@codemirror/autocomplete": "npm:6.20.3"
"@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.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"
"@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"
"@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.2"
"@tsparticles/preset-links": "npm:4.1.2"
"@tsparticles/engine": "npm:4.1.3"
"@tsparticles/preset-links": "npm:4.1.3"
"@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.1"
fuse.js: "npm:7.4.2"
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.7"
intl-messageformat: "npm:11.2.8"
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.4"
marked: "npm:18.0.5"
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.1"
rspack-manifest-plugin: "npm:5.2.2"
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.7":
version: 11.2.7
resolution: "intl-messageformat@npm:11.2.7"
"intl-messageformat@npm:11.2.8":
version: 11.2.8
resolution: "intl-messageformat@npm:11.2.8"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.5"
"@formatjs/icu-messageformat-parser": "npm:3.5.10"
checksum: 10/ccd566358c90c7d33fbc71206d46501c18e66dac10cfdab1ea226bc16c3b31d25d987619ef355ca24c5b9f415075d2abe5cf383c69dbbe758bd78e7f72017ab9
"@formatjs/fast-memoize": "npm:3.1.6"
"@formatjs/icu-messageformat-parser": "npm:3.5.11"
checksum: 10/451585274cdb1ff798b8d9bfa9aed6623870d2a3bf7c315a4cc37e57793d13a51ec09da705a228f7a64a1267fd6afe91b5bf2fd0c4223fd4f1d158a2adb82d41
languageName: node
linkType: hard
@@ -10221,12 +10221,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.4":
version: 18.0.4
resolution: "marked@npm:18.0.4"
"marked@npm:18.0.5":
version: 18.0.5
resolution: "marked@npm:18.0.5"
bin:
marked: bin/marked.js
checksum: 10/87d8e309690c9b8e1a5c41487262f19c811e876b8351fbb24432ab348e79c59ee7c68745ec47bfc91c1306a1756077717ff564454e7a68a58a5bd064d732d5dd
checksum: 10/61f1c64304a7e2615ac096bd7b6e5da453561a57393a57f85f8ddef07a64eaeadf4d57c4c6e47d9179d116d36326c159d15993490686fc17434a39bdab9ddb8a
languageName: node
linkType: hard
@@ -12019,17 +12019,17 @@ __metadata:
languageName: node
linkType: hard
"rspack-manifest-plugin@npm:5.2.1":
version: 5.2.1
resolution: "rspack-manifest-plugin@npm:5.2.1"
"rspack-manifest-plugin@npm:5.2.2":
version: 5.2.2
resolution: "rspack-manifest-plugin@npm:5.2.2"
dependencies:
"@rspack/lite-tapable": "npm:^1.0.1"
peerDependencies:
"@rspack/core": ^1.0.0 || ^2.0.0-0
"@rspack/core": ^1.0.0 || ^2.0.0
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/fe0ddf92a881e45859f8dc4991823e3773292cfbd087db0d83d5856785069b1774139116fe0ca0adc02bdca8f7392165a990b104bc0298f4e1824c9d73672b05
checksum: 10/1acae737b83bcb16d4d9fa07a2b8542c97844bc7328ebdca4db76d3d010f507eeb9ba05f639dc01961234fedbe99dc49d7c128533b415be13a75b91e21101410
languageName: node
linkType: hard