Compare commits

...

68 Commits

Author SHA1 Message Date
Stefan Agner 78d4072049 Explain isolated network behavior on the app network card
Add an info alert to the isolated network access section covering what
users should know about the implementation: the app appears as a
separate device, IPv6 is automatic via SLAAC, the host and the app
cannot reach each other on the isolated network by design (Supervisor
communication like ingress is unaffected), and apps that introspect
host interfaces may behave differently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:06:14 +02:00
Stefan Agner f2827d0ab6 Show the endpoint MAC address on the app network card
Display the saved endpoint MAC below the IP address field of the
isolated network access section, so users can set up router or
firewall rules right where they configure the endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:55:02 +02:00
Stefan Agner 22b83b20fc Show the MAC address of isolated app network endpoints
The Supervisor now reports network_isolation_mac, a stable MAC derived
from the static IP that is known as soon as isolation is configured.
Show it next to the IP on the app info tab, including while the app is
stopped, so users can create router or firewall rules before the first
start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:06:46 +02:00
Stefan Agner 65f7d65462 Add isolated network access settings for apps
Apps that use host networking can now be switched to an isolated
network endpoint (macvlan) from the network card on the Configuration
tab, when the Supervisor reports network_isolation_available. The card
gains a toggle, an interface select populated from the
network_isolation_capable host interfaces, and an IPv4 address field.
Saving posts the network_isolation option and suggests an app restart,
same as port changes. The info tab shows the assigned IP while the app
is running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:04:24 +02:00
renovate[bot] 382e07379b Update CodeMirror (#52567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 13:24:05 +02:00
Andreas Schneider 01a8b8d3ef Pass network_only: false when commissioning Matter devices (#52456)
The backend defaults network_only to True, which causes BLE
commissioning to be skipped even when BLE proxies are connected. Passing
false lets the Matter server use BLE when proxies are available while
falling back to network commissioning when they are not.

It is only set to false for the following path:

Settings -> Matter -> Options -> Add manually
2026-06-12 09:33:42 +03:00
renovate[bot] 3bbce5607e Update typescript-eslint monorepo to v8.61.0 (#52562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 21:58:53 +02:00
Petar Petrov 7ce052e2a8 Reduce allocations in downSampleLineData (#52551)
Replace the per-point grouping (a Map of arrays of {point, x, y} wrapper
objects, plus a second pass that re-walks each frame and, in mean mode,
two reduce() passes) with a single fixed accumulator per frame:

- mean mode keeps running sumX/sumY/count in arrival order, so the
  floating-point summation order is unchanged
- min/max mode tracks the min and max point incrementally with the same
  strict comparisons (first occurrence wins on ties) and the same
  min-before-max emit ordering

Each point is touched once; ~100k transient objects per large payload are
eliminated. Output is bit-identical. Speedups range from ~1.2x (mid-size)
to ~1.5-1.8x (100k-point) payloads, with substantially lower run-to-run
variance from reduced GC pressure. Payloads below maxDetails still
early-return unchanged.
2026-06-11 19:57:31 +02:00
Petar Petrov e929558a9a Fix clipped descenders in chart legend labels (#52554) 2026-06-11 19:47:16 +02:00
Aidan Timson 9cd4a6937f Migrate backup dialogs to dirty state provider and dialog behavior (#52549) 2026-06-11 17:12:57 +03:00
karwosts af617695b8 Make 'Add Card' more robust to bad yaml (#52556) 2026-06-11 17:08:05 +03:00
Aidan Timson 740ad9eb6b Migrate to dirty state provider for 5 dialogs (#52546)
* Add DirtyStateProviderMixin to dialogs (areas, backup, energy, helpers, person, voice assistant)

Migrate dialogs to DirtyStateProviderMixin for dirty state tracking via
Lit context. These dialogs do not use PreventUnsavedMixin and are fully
independent of the PreventUnsavedMixin contract change.

* Add disabled state

* Fix disable state

* Add check
2026-06-11 17:03:37 +03:00
Aidan Timson caeedc41e3 Migrate second set of dashboard dialogs to dirty state provider (#52538)
* Migrate second set of dashboard dialogs to dirty state provider

* Fix create dialog

* Add DirtyStateProviderMixin to Lovelace raw config editor

* Fix yaml mode check from review

* Fix
2026-06-11 16:46:24 +03:00
Bram Kragten fbb76a8ba0 Filter expired camera/image proxy requests in service worker (#52534)
Pre-validate the credential on camera_proxy, camera_proxy_stream and
image_proxy URLs before letting them hit core. Requests with a missing
or "undefined" token, or with an authSig JWT whose exp has passed, are
short-circuited to a synthetic 401 and never reach the server.

This silences spurious "Login attempt or request with invalid
authentication" warnings from homeassistant.components.http.ban that
fire when the browser replays a stale <img src> after BFCache restore,
tab resume, or a network change. The signed-path TTL is short (30s by
default) and image elements happily hold onto the URL long after that.

Limitations: service workers only run on secure contexts, so this does
not help users on plain http LAN access. A core-side fix to ban.py
that distinguishes expired-but-validly-signed paths from real login
attempts remains the principled fix and covers all clients.
2026-06-11 16:35:32 +03:00
Aidan Timson 3340637ff3 Migrate to dirty state provider for 4 areas (#52545)
* Add DirtyStateProviderMixin to dialogs (config entry, credentials, supervisor apps, voice assistant, zones)

Migrate dialogs to DirtyStateProviderMixin for dirty state tracking via
Lit context. These dialogs do not use PreventUnsavedMixin and are fully
independent of the PreventUnsavedMixin contract change.

* Fix disabled state

* Fix disabled state

* Drop voice assistant pipeline dialog changes (handled in dialogs-b)
2026-06-11 16:21:01 +03:00
Petar Petrov 534bea231c Open more-info from energy pie chart legend, enlarge legend toggle on touch (#52506) 2026-06-11 15:18:46 +02:00
Aidan Timson 8635951394 Migrate user dialogs to use dirty state provider (#52537)
* Migrate user dialogs to use dirty state provider

* Restore original dialog setups
2026-06-11 15:14:26 +03:00
Aidan Timson c46f286cb8 Gate more info "Add to" button to admins (#52547) 2026-06-11 13:12:39 +02:00
Petar Petrov cc6b51d53f Show a single toast with the update name when Update all fails (#52530) 2026-06-11 12:06:47 +01:00
karwosts 6915ca8fdd Remove dead code in hui-timestamp-display (#52544) 2026-06-11 08:40:55 +03:00
Jan-Philipp Benecke 677e53f685 Migrate maintenance panel topbar to ha-top-app-bar-fixed (#52540) 2026-06-11 08:40:08 +03:00
Aidan Timson 46b6ae8d7b Remove isDirty from PreventUnsavedMixin, migrate automation editors to DirtyStateProviderMixin (#52515)
* Remove isDirty from PreventUnsavedMixin, migrate to DirtyStateProviderMixin

Drop the legacy isDirty getter from PreventUnsavedMixin and switch to
isDirtyState (provided by DirtyStateProviderMixin). All consumers that
previously overrode isDirty are migrated to use DirtyStateProviderMixin
with proper dirty tracking:

- AutomationScriptEditorMixin: deep config comparison
- ha-scene-editor: revision counter with shallow comparison
- Blueprint editors + manual editor mixin: consume dirty context

This is the atomic core change — all isDirty overriders are migrated in
the same commit so no compatibility layer is needed.

* Fix dirty state not updated when YAML editor has invalid content

- ha-scene-editor: call _updateDirtyState on invalid YAML to increment
  the revision counter, marking the editor dirty
- ha-script-editor, ha-automation-editor: override isDirtyState to also
  return true when yamlErrors is set, ensuring the PreventUnsavedMixin
  navigation guard fires even when the user has never produced a valid
  intermediate config change

* FIx/migrate editor-toast

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-11 08:36:19 +03:00
Aidan Timson 09fda1ca1e Migrate energy dialogs to use dirty state provider (#52535)
* Migrate energy dialogs to use dirty state provider

* Migrate energy grid settings dialog

* Fix baseline for add mode

* Review
2026-06-11 08:28:39 +03:00
Jan-Philipp Benecke 7c1522b975 Migrate hass-error-screen to ha-top-app-bar-fixed (#52543) 2026-06-11 07:06:36 +02:00
Jan-Philipp Benecke d26ad7b354 Migrate hass-loading-screen to ha-top-app-bar-fixed (#52542) 2026-06-11 07:06:26 +02:00
Bram Kragten 66235a4c99 Don't try to load brand images without a token (#52532) 2026-06-10 18:09:35 +02:00
dependabot[bot] 6c02864334 Bump shell-quote from 1.8.3 to 1.8.4 (#52533)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 15:15:35 +03:00
Aidan Timson 3471cd103a Add a blocking labels workflow (#52531) 2026-06-10 13:53:51 +02:00
Jan-Philipp Benecke 9ae25d96f2 Move default menu/back button to ha-top-app-bar-fixed (#52444) 2026-06-10 12:11:09 +02:00
Marcin Bauer 02361f2517 Show condition row icon on mobile in visibility editor (#52527)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:30:37 +02:00
Aidan Timson 38055b9244 Migrate vaccum segment mapping to dirty state provider (#52510)
* Migrate vaccum segment mapping to dirty state provider

* Use typed value-changed event in vacuum mapping view

* Use consumer key for vaccum segment mapping instead of new provider

* Completeness

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-10 09:49:01 +03:00
Aidan Timson d064127f18 Migrate lovelace editors to dirty state provider (#52509)
* Migrate lovelace editors to dirty state provider

* Type in signature

* Type in signature

* Keep saving
2026-06-10 08:45:11 +03:00
Jan-Philipp Benecke cb2d8db91b Migrate security panel topbar to ha-top-app-bar-fixed (#52519) 2026-06-10 08:33:28 +03:00
Joakim Plate 861d7757cc If redirect url has a trailing question mark our redirect is malformed (#52526) 2026-06-10 08:18:22 +03:00
Bram Kragten 1331ec9e2d Add condition live testing to action conditions too (#52511)
* Add condition live testing to action conditions too

* Update src/panels/config/automation/action/ha-automation-action-row.ts

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

* Apply prettier formatting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:14:30 +03:00
Bram Kragten 0f81311c76 Fix camera/image proxy URLs sent with token=undefined (#52514) 2026-06-09 15:34:49 +01:00
Aidan Timson 8a85d1cf31 Use typed query param handling in todo and refactor handler typing (#52505)
* Use typed query param handlers for todo

* Refactor to query param config obj

* Remove type casts

* Use main window

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

* Fix import

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-09 11:40:45 +00:00
Przemysław Szypowicz 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 <2733699+pszypowicz@users.noreply.github.com>
2026-06-09 14:36:34 +03:00
Jan-Philipp Benecke f0f28789de Fix scrolling behavior for auto-height data table (#52508) 2026-06-09 14:21:40 +03:00
Jan-Philipp Benecke f007ea9da1 Fix disabled action items icon button color in hui edit mode (#52507) 2026-06-09 14:02:57 +03:00
Aidan Timson 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 Timson 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
Jan-Philipp Benecke 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 <norbert@rittel.de>

* Process code review

* Fix translation

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-09 06:16:33 +00:00
renovate[bot] 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 Timson 914b90ffae Add typed query param helpers, add to history and activity (#52439) 2026-06-09 08:11:32 +03:00
ildar170975 1f1d520fdf Plant status card: add color for battery (#52457)
add color for battery
2026-06-08 14:16:14 +03:00
Bram Kragten 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] 0859202043 Update dependency rspack-manifest-plugin to v5.2.2 (#52492) 2026-06-08 08:23:51 +01:00
karwosts 125629ed39 Prevent spurious error flashes during helpers table load (#52479) 2026-06-08 08:34:18 +03:00
Abílio Costa bbfa71e974 Fix "show_legend" default in energy graph card editor (#52486) 2026-06-08 08:31:52 +03:00
Paulus Schoutsen 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 <noreply@anthropic.com>
2026-06-08 08:31:13 +03:00
Paulus Schoutsen 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 <noreply@anthropic.com>
2026-06-08 08:30:09 +03:00
Paulus Schoutsen 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 <noreply@anthropic.com>
2026-06-07 22:33:21 -04:00
karwosts b316ef5f45 Fix yaml entity autocomplete (#52475) 2026-06-07 20:17:47 +02:00
renovate[bot] 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] 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 Dalen 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
ildar170975 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
Paulus Schoutsen 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 <noreply@anthropic.com>
2026-06-07 09:55:02 +03:00
Paulus Schoutsen 04ae6eb3b4 Always show volume controls for assumed state media players in more info (#52466)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 09:54:06 +03:00
karwosts 089849d283 Improve table no-data messages (#52474) 2026-06-07 09:52:19 +03:00
dependabot[bot] 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 08:19:22 +02:00
renovate[bot] 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] 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
Simon Lamon 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 <balloob@gmail.com>
2026-06-06 11:42:55 -04:00
Paulus Schoutsen fab4022dee Remove unused PageNavigation.not_component (#52463)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-06 11:37:55 +02:00
karwosts d70a42930b Fix helper no category message (#52462) 2026-06-06 11:35:09 +02:00
Paulus Schoutsen b91a087ab1 Fix oversized top-app-bar-fixed-adjust wrapper in gallery pages (#52467) 2026-06-06 08:00:39 +02:00
180 changed files with 7304 additions and 3670 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
+50
View File
@@ -0,0 +1,50 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
+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="you@example.com"
></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="you@example.com"
></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
+19 -19
View File
@@ -29,26 +29,26 @@
"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",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.6",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@codemirror/view": "6.43.1",
"@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,14 +187,14 @@
"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",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"typescript-eslint": "8.61.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
+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,58 @@
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 const historyLogbookTargetParamKeys: readonly 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" }],
} as const satisfies QueryParamConfig;
export type HistoryLogbookQueryParams = QueryParamValues<
typeof historyLogbookQueryParamConfig
>;
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;
};
+172
View File
@@ -0,0 +1,172 @@
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 {
list?: readonly string[];
date?: readonly string[];
boolean?: readonly {
key: string;
trueValue: string;
}[];
string?: readonly string[];
}
type ListKeyOf<C extends QueryParamConfig> = C extends {
list: readonly (infer K extends string)[];
}
? K
: never;
type DateKeyOf<C extends QueryParamConfig> = C extends {
date: readonly (infer K extends string)[];
}
? K
: never;
type BooleanKeyOf<C extends QueryParamConfig> = C extends {
boolean: readonly { key: infer K extends string }[];
}
? K
: never;
type StringKeyOf<C extends QueryParamConfig> = C extends {
string: readonly (infer K extends string)[];
}
? K
: never;
export type QueryParamValues<C extends QueryParamConfig> = Partial<
Record<ListKeyOf<C>, string[]> &
Record<DateKeyOf<C>, Date> &
Record<BooleanKeyOf<C>, boolean> &
Record<StringKeyOf<C>, string>
>;
type QueryParamValue = string[] | Date | boolean | string;
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 function decodeQueryParams<C extends QueryParamConfig>(
searchParams: SearchParamsSource,
config: C
): QueryParamValues<C>;
export function decodeQueryParams(
searchParams: SearchParamsSource,
config: QueryParamConfig
): Record<string, QueryParamValue | undefined> {
const params: Record<string, QueryParamValue> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",");
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value);
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true;
}
}
for (const key of config.string ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value;
}
}
return params;
}
export function createQueryString<C extends QueryParamConfig>(
values: QueryParamValues<NoInfer<C>>,
config: C
): string;
export function createQueryString(
values: Record<string, QueryParamValue | undefined>,
config: QueryParamConfig
): string {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key];
if (Array.isArray(value) && value.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key];
if (value instanceof Date) {
searchParams.append(key, value.toISOString());
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (values[key]) {
searchParams.append(key, trueValue);
}
}
for (const key of config.string ?? []) {
const value = values[key];
if (typeof value === "string" && value) {
searchParams.append(key, value);
}
}
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;
};
+21
View File
@@ -0,0 +1,21 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const todoQueryParamConfig = {
string: ["entity_id"],
boolean: [{ key: "add_item", trueValue: "true" }],
} as const satisfies QueryParamConfig;
export type TodoQueryParams = QueryParamValues<typeof todoQueryParamConfig>;
export const decodeTodoQueryParams = (
searchParams: SearchParamsSource
): TodoQueryParams => decodeQueryParams(searchParams, todoQueryParamConfig);
export const createTodoQueryString = (values: TodoQueryParams): string =>
createQueryString(values, todoQueryParamConfig);
@@ -0,0 +1,144 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import type { Condition } from "../../data/automation";
import { subscribeCondition } from "../../data/automation";
import type { HomeAssistant } from "../../types";
import "../ha-tooltip";
import "./ha-automation-row-live-test";
import type { LiveTestState } from "./ha-automation-row-live-test";
@customElement("ha-automation-condition-live-test")
export class HaAutomationConditionLiveTest extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: Condition;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
private _conditionUnsub?: Promise<UnsubscribeFunc>;
public connectedCallback(): void {
super.connectedCallback();
this._subscribeCondition();
}
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("condition") &&
changedProps.get("condition") !== undefined
) {
this._resetSubscription();
this._debounceSubscribeCondition();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._debounceSubscribeCondition.cancel();
this._resetSubscription();
}
protected render() {
return html`
<div id="indicator">
<slot></slot>
<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>
</div>
${this._liveTestResult.message
? html`<ha-tooltip for="indicator"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
`;
}
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
}
}
private _debounceSubscribeCondition = debounce(
() => this._subscribeCondition(),
500
);
private async _subscribeCondition() {
this._resetSubscription();
if (!this.condition) {
return;
}
const conditionUnsub = subscribeCondition(
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
);
conditionUnsub.catch((err: any) => {
this._handleLiveTestError(err);
if (this._conditionUnsub === conditionUnsub) {
this._conditionUnsub = undefined;
}
});
this._conditionUnsub = conditionUnsub;
}
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
static styles = css`
:host {
display: inline-flex;
position: relative;
}
#indicator {
display: inline-flex;
position: relative;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-live-test": HaAutomationConditionLiveTest;
}
}
@@ -161,6 +161,8 @@ export class HaAutomationRow extends LitElement {
}
.leading-icon-wrapper {
padding-top: var(--ha-space-3);
position: relative;
z-index: 1;
}
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
+86 -46
View File
@@ -1,5 +1,23 @@
import type { LineSeriesOption } from "echarts";
type Point = NonNullable<LineSeriesOption["data"]>[number];
interface MeanFrame {
sumX: number;
sumY: number;
count: number;
isArray: boolean;
}
interface MinMaxFrame {
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -19,11 +37,47 @@ export function downSampleLineData<
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
const frames = new Map<number, MeanFrame>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
sumX: x,
sumY: y,
count: 1,
isArray: Array.isArray(pointData),
});
} else {
frame.sumX += x;
frame.sumY += y;
frame.count += 1;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
const meanX = frame.sumX / frame.count;
const meanY = frame.sumY / frame.count;
const meanPoint = (
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
return result;
}
// Min/max mode: track the min and max point per frame in insertion order.
const frames = new Map<number, MinMaxFrame>();
for (const point of data) {
const pointData = getPointData(point);
@@ -35,53 +89,39 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, [{ point, x, y }]);
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frame.push({ point, x, y });
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
}
if (y > frame.maxY) {
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
}
// Convert frames back to points
const result: T[] = [];
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
+22 -1
View File
@@ -1520,7 +1520,9 @@ export class HaChartBase extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
line-height: 1;
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
line-height, so give the line box room to contain them */
line-height: var(--ha-line-height-condensed);
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
@@ -1558,6 +1560,25 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
+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}
+9 -5
View File
@@ -112,12 +112,16 @@ export class HaCameraStream extends LitElement {
return nothing;
}
if (stream.type === MJPEG_STREAM) {
const streamUrl = __DEMO__
? this.stateObj.attributes.entity_picture
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl;
if (!streamUrl) {
return nothing;
}
return html`<img
.src=${__DEMO__
? this.stateObj.attributes.entity_picture!
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl || ""}
.src=${streamUrl}
style=${styleMap({
aspectRatio: this.aspectRatio,
objectFit: this.fitMode,
+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;
}
}
+27 -5
View File
@@ -2,6 +2,9 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
@@ -135,6 +138,8 @@ export const haTopAppBarFixedStyles = css`
export class HaTopAppBarFixed extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "back-button", type: Boolean }) backButton = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@query(".top-app-bar") protected _barElement!: HTMLElement;
@@ -200,16 +205,14 @@ export class HaTopAppBarFixed extends LitElement {
<div class="row">
${paneHeader
? html`<section class="section" id="title">
<slot name="navigationIcon"></slot>
${title}
${this._renderNavigationIcon()} ${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
: html`${this._renderNavigationIcon()}
${this.centerTitle ? nothing : title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
@@ -225,6 +228,20 @@ export class HaTopAppBarFixed extends LitElement {
`;
}
private _renderNavigationIcon() {
return html`
<slot name="navigationIcon">
${this.backButton
? html`
<ha-icon-button-arrow-prev
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button></ha-menu-button>`}
</slot>
`;
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
@@ -268,6 +285,11 @@ export class HaTopAppBarFixed extends LitElement {
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
};
private _handleBackClick(ev: Event) {
ev.stopPropagation();
goBack();
}
protected _registerListeners() {
this.scrollTarget.addEventListener(
"scroll",
+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" });
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const redirectWithAuthCode = (
// OAuth 2: 3.1.2 we need to retain query component of a redirect URI
if (!url.includes("?")) {
url += "?";
} else if (!url.endsWith("&")) {
} else if (!url.endsWith("?") && !url.endsWith("&")) {
url += "&";
}
+7 -3
View File
@@ -17,7 +17,7 @@ export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
interface CameraEntityAttributes extends HassEntityAttributeBase {
model_name: string;
access_token: string;
access_token?: string;
brand: string;
motion_detection: boolean;
frontend_stream_type: string;
@@ -78,8 +78,12 @@ export const cameraUrlWithWidthHeight = (
height: number
) => `${base_url}&width=${width}&height=${height}`;
export const computeMJPEGStreamUrl = (entity: CameraEntity) =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
export const computeMJPEGStreamUrl = (
entity: CameraEntity
): string | undefined =>
entity.attributes.access_token
? `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`
: undefined;
export const fetchThumbnailUrlWithCache = async (
hass: HomeAssistant,
+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");
+13
View File
@@ -44,6 +44,15 @@ interface AddonTranslations {
configuration?: Record<string, AddonFieldTranslation>;
}
export interface AddonNetworkIsolationParams {
interface: string;
ipv4: string;
}
export interface AddonNetworkIsolation extends AddonNetworkIsolationParams {
driver: "macvlan";
}
export interface HassioAddonInfo {
advanced: boolean;
available: boolean;
@@ -100,6 +109,9 @@ export interface HassioAddonDetails extends HassioAddonInfo {
long_description: null | string;
machine: any;
network_description: null | Record<string, string>;
network_isolation: AddonNetworkIsolation | null;
network_isolation_available: boolean;
network_isolation_mac: string | null;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
@@ -143,6 +155,7 @@ export interface HassioAddonSetOptionParams {
auto_update?: boolean;
ingress_panel?: boolean;
network?: Record<string, unknown> | null;
network_isolation?: AddonNetworkIsolationParams | null;
watchdog?: boolean;
}
+1
View File
@@ -19,6 +19,7 @@ export interface NetworkInterface {
ipv6?: Partial<IpConfiguration>;
type: "ethernet" | "wireless" | "vlan";
wifi?: Partial<WifiConfiguration> | null;
network_isolation_capable?: boolean;
}
export interface DockerNetwork {
+5 -3
View File
@@ -4,12 +4,14 @@ import type {
} from "home-assistant-js-websocket";
interface ImageEntityAttributes extends HassEntityAttributeBase {
access_token: string;
access_token?: string;
}
export interface ImageEntity extends HassEntityBase {
attributes: ImageEntityAttributes;
}
export const computeImageUrl = (entity: ImageEntity): string =>
`/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
export const computeImageUrl = (entity: ImageEntity): string | undefined =>
entity.attributes.access_token
? `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`
: undefined;
+3 -1
View File
@@ -128,11 +128,13 @@ export const addMatterDevice = (hass: HomeAssistant) => {
export const commissionMatterDevice = (
hass: HomeAssistant,
code: string
code: string,
networkOnly: boolean
): Promise<void> =>
hass.callWS({
type: "matter/commission",
code,
network_only: networkOnly,
});
export const acceptSharedMatterDevice = (
+14 -4
View File
@@ -146,10 +146,20 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const installUpdates = (
hass: HomeAssistant,
entityIds: string[],
notifyOnError = true
) =>
hass.callService(
"update",
"install",
{
entity_id: entityIds,
},
undefined,
notifyOnError
);
export const checkForEntityUpdates = async (
element: HTMLElement,
@@ -10,13 +10,21 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends LitElement {
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -38,6 +46,13 @@ class DialogConfigEntrySystemOptions extends LitElement {
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -68,7 +83,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
) || this._params.entry.domain,
}
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -135,7 +150,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -149,11 +164,19 @@ class DialogConfigEntrySystemOptions extends LitElement {
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
@@ -106,7 +106,9 @@ class EntityPreviewRow extends LitElement {
}
`;
private _renderEntityState(stateObj: HassEntity): TemplateResult | string {
private _renderEntityState(
stateObj: HassEntity
): TemplateResult | string | typeof nothing {
const domain = stateObj.entity_id.split(".", 1)[0];
const disabled = stateObj.state === UNAVAILABLE;
const noValue =
@@ -216,7 +218,10 @@ class EntityPreviewRow extends LitElement {
}
if (domain === "image") {
const image: string = computeImageUrl(stateObj as ImageEntity);
const image = computeImageUrl(stateObj as ImageEntity);
if (!image) {
return nothing;
}
return html`
<img
alt=${ifDefined(stateObj?.attributes.friendly_name)}
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -6,6 +7,10 @@ import "../../../../components/ha-button";
import "../../../../components/ha-spinner";
import "../../../../components/ha-vacuum-segment-area-mapper";
import type { HaVacuumSegmentAreaMapper } from "../../../../components/ha-vacuum-segment-area-mapper";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../../data/context/dirty-state";
import type {
ExtEntityRegistryEntry,
VacuumEntityOptions,
@@ -14,7 +19,7 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
@customElement("ha-more-info-view-vacuum-segment-mapping")
export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
@@ -22,12 +27,17 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
@property({ attribute: false }) public params!: { entityId: string };
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext<
Record<string, string[]>,
"vacuum-segment-mapping"
>;
@state() private _areaMapping?: Record<string, string[]>;
@state() private _submitting = false;
@state() private _dirty = false;
@state() private _error?: string;
private _entry?: ExtEntityRegistryEntry;
@@ -44,16 +54,15 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
this.params.entityId
);
if (this._entry?.options?.vacuum) {
this._areaMapping = this._entry.options.vacuum.area_mapping || {};
} else {
this._areaMapping = {};
}
const mapping: Record<string, string[]> =
this._entry?.options?.vacuum?.area_mapping || {};
this._areaMapping = mapping;
this._dirtyState?.setState(mapping, "vacuum-segment-mapping");
}
private _valueChanged(ev: CustomEvent) {
private _valueChanged(ev: ValueChangedEvent<Record<string, string[]>>) {
this._areaMapping = ev.detail.value;
this._dirty = true;
this._dirtyState?.setState(ev.detail.value, "vacuum-segment-mapping");
}
private async _save() {
@@ -77,7 +86,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
options_domain: "vacuum",
options: options,
});
this._dirty = false;
this._dirtyState?.markClean();
fireEvent(this, "close-child-view");
} catch (err: any) {
this._error = err.message;
@@ -107,7 +116,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
<div class="footer">
<ha-button
@click=${this._save}
.disabled=${!this._dirty || this._submitting}
.disabled=${!this._dirtyState?.isDirty || this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -15,9 +15,13 @@ class MoreInfoImage extends LitElement {
if (!this.hass || !this.stateObj) {
return nothing;
}
const imageUrl = computeImageUrl(this.stateObj);
if (!imageUrl) {
return nothing;
}
return html`<img
alt=${this.stateObj.attributes.friendly_name || this.stateObj.entity_id}
src=${this.hass.hassUrl(computeImageUrl(this.stateObj))}
src=${this.hass.hassUrl(imageUrl)}
/> `;
}
@@ -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
+3 -1
View File
@@ -40,7 +40,9 @@ export class HaMoreInfoAddTo extends LitElement {
@state() private _loading = true;
private async _loadActions() {
this._defaultActions = getDefaultAddToActions();
this._defaultActions = this._config?.user?.is_admin
? getDefaultAddToActions()
: [];
this._externalActions = [];
if (this._config?.auth.external?.config.hasEntityAddTo) {
+80 -58
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 | Record<string, string[]> | null,
"entity-registry" | "helper" | "vacuum-segment-mapping"
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -262,9 +266,8 @@ export class MoreInfoDialog extends SubscribeMixin(
}
private _shouldShowAddEntityTo(): boolean {
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
return (
this._newTriggersAndConditions ||
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
@@ -633,6 +636,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 +655,9 @@ 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._childView) &&
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;
+67
View File
@@ -19,6 +19,64 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
const noFallBackRegEx =
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
// Camera / image proxy endpoints that carry credentials in the URL.
// We pre-validate the credential in the service worker so obviously invalid
// requests (signature expired, token missing) never reach the server and
// don't trigger spurious "Login attempt" warnings from http.ban after BFCache
// restore, tab resume, network change, or any other browser-initiated replay
// of a stale `<img>` URL.
const proxyPathRegEx =
/^\/api\/(camera_proxy_stream|camera_proxy|image_proxy)\//;
// Reject signatures this many ms before their nominal expiry to absorb small
// client/server clock differences. Erring this direction only ever turns a
// would-be valid request into a local 401; we cannot err the other way without
// re-introducing the warnings this filter exists to prevent.
const JWT_EXPIRY_SKEW_MS = 5000;
const base64UrlDecode = (input: string): string => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const isJwtExpired = (jwt: string): boolean => {
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
return false;
}
const payload = JSON.parse(base64UrlDecode(parts[1]));
if (typeof payload.exp !== "number") {
return false;
}
return payload.exp * 1000 < Date.now() + JWT_EXPIRY_SKEW_MS;
} catch (_err) {
// If we can't parse the JWT for any reason, defer to the server.
return false;
}
};
const handleProxyRequest: RouteHandler = async ({ request }) => {
const req = request as Request;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (token === "undefined" || token === "null" || token === "") {
return new Response(null, { status: 401, statusText: "Invalid token" });
}
const authSig = url.searchParams.get("authSig");
if (authSig && isJwtExpired(authSig)) {
return new Response(null, {
status: 401,
statusText: "Signature expired",
});
}
return fetch(req);
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
@@ -59,6 +117,15 @@ const initRouting = () => {
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
registerRoute(
({ url, request }) =>
proxyPathRegEx.test(url.pathname) && request.method === "GET",
handleProxyRequest
);
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
@@ -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) => {
+17 -35
View File
@@ -2,9 +2,8 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { goBack } from "../common/navigate";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-button";
import "../components/ha-menu-button";
import "../components/ha-top-app-bar-fixed";
import type { HomeAssistant } from "../types";
import "../components/ha-alert";
@@ -21,18 +20,22 @@ class HassErrorScreen extends LitElement {
@property() public error?: string;
protected render(): TemplateResult {
if (!this.toolbar) {
return this._renderContent();
}
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
`;
}
private _renderContent(): TemplateResult {
return html`
${this.toolbar
? html`<div class="toolbar">
${this.rootnav || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
</div>`
: ""}
<div class="content">
<ha-alert alert-type="error">${this.error}</ha-alert>
<slot>
@@ -56,30 +59,9 @@ class HassErrorScreen extends LitElement {
height: 100%;
background-color: var(--primary-background-color);
}
.toolbar {
display: flex;
align-items: center;
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 8px 12px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 4px;
}
}
ha-icon-button-arrow-prev {
pointer-events: auto;
}
.content {
color: var(--primary-text-color);
height: calc(100% - var(--header-height));
height: 100%;
display: flex;
padding: 16px;
align-items: center;
+34 -64
View File
@@ -1,11 +1,8 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { goBack } from "../common/navigate";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("hass-loading-screen")
@@ -22,18 +19,22 @@ class HassLoadingScreen extends LitElement {
@property() public message?: string;
protected render(): TemplateResult {
if (this.noToolbar) {
return this._renderContent();
}
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!(this.rootnav || history.state?.root)}
>
${this._renderContent()}
</ha-top-app-bar-fixed>
`;
}
private _renderContent(): TemplateResult {
return html`
${this.noToolbar
? ""
: html`<div class="toolbar">
${this.rootnav || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: html`
<ha-icon-button-arrow-prev
@click=${this._handleBack}
></ha-icon-button-arrow-prev>
`}
</div>`}
<div class="content">
<ha-spinner></ha-spinner>
${this.message
@@ -43,55 +44,24 @@ class HassLoadingScreen extends LitElement {
`;
}
private _handleBack() {
goBack();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
.toolbar {
display: flex;
align-items: center;
font-size: var(--ha-font-size-xl);
height: var(--header-height);
padding: 8px 12px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
padding: 4px;
}
}
ha-menu-button,
ha-icon-button-arrow-prev {
pointer-events: auto;
}
.content {
height: calc(100% - var(--header-height));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loading-text {
max-width: 350px;
margin-top: 16px;
}
`,
];
}
static styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
background-color: var(--primary-background-color);
}
.content {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#loading-text {
max-width: 350px;
margin-top: var(--ha-space-4);
}
`;
}
declare global {
-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`. */
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;
}
-4
View File
@@ -16,7 +16,6 @@ import type { HaDropdownItem } from "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
@@ -119,7 +118,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
if (!this._entityRegistry) {
return html`
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">
${this.hass.localize("ui.components.calendar.my_calendars")}
</div>
@@ -154,8 +152,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
footer
.narrow=${this.narrow}
>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
${!showPane
? html`<ha-dropdown slot="title">
<ha-button slot="trigger">
+4 -17
View File
@@ -1,11 +1,8 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -90,22 +87,12 @@ class PanelClimate extends LitElement {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button slot="navigationIcon"></ha-menu-button>`}
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${this._lovelace
? html`
@@ -23,18 +23,28 @@ import {
} from "../../../data/application_credential";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface CredentialFormState {
domain: string;
name: string;
clientId: string;
clientSecret: string;
}
interface Domain {
id: string;
name: string;
}
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends LitElement {
export class DialogAddApplicationCredential extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -76,6 +86,7 @@ export class DialogAddApplicationCredential extends LitElement {
this._error = undefined;
this._loading = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, this._currentState());
this._fetchConfig();
}
@@ -100,10 +111,7 @@ export class DialogAddApplicationCredential extends LitElement {
<ha-dialog
.open=${this._open}
@closed=${this._abortDialog}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.preventScrimClose=${this.isDirtyState}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)}
@@ -284,6 +292,7 @@ export class DialogAddApplicationCredential extends LitElement {
ev.stopPropagation();
this._domain = ev.detail.value;
this._updateDescription();
this._updateDirtyState(this._currentState());
}
private async _updateDescription() {
@@ -307,6 +316,16 @@ export class DialogAddApplicationCredential extends LitElement {
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
this._updateDirtyState(this._currentState());
}
private _currentState(): CredentialFormState {
return {
domain: this._domain || "",
name: this._name || "",
clientId: this._clientId || "",
clientSecret: this._clientSecret || "",
};
}
private _abortDialog() {
@@ -29,11 +29,13 @@ class SupervisorAppConfigDashboard extends LitElement {
const hasConfiguration =
(this.addon.options && Object.keys(this.addon.options).length) ||
(this.addon.schema && Object.keys(this.addon.schema).length);
const hasNetwork =
this.addon.network || this.addon.network_isolation_available;
return html`
<div class="content">
${this.addon.system_managed &&
(hasConfiguration || this.addon.network || this.addon.audio)
(hasConfiguration || hasNetwork || this.addon.audio)
? html`
<supervisor-app-system-managed
.hass=${this.hass}
@@ -42,7 +44,7 @@ class SupervisorAppConfigDashboard extends LitElement {
></supervisor-app-system-managed>
`
: nothing}
${hasConfiguration || this.addon.network || this.addon.audio
${hasConfiguration || hasNetwork || this.addon.audio
? html`
${hasConfiguration
? html`
@@ -54,7 +56,7 @@ class SupervisorAppConfigDashboard extends LitElement {
></supervisor-app-config>
`
: nothing}
${this.addon.network
${hasNetwork
? html`
<supervisor-app-network
.hass=${this.hass}
@@ -32,6 +32,7 @@ import {
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { ObjectSelector, Selector } from "../../../../../data/selector";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@@ -56,15 +57,15 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
const MASKED_FIELDS = ["password", "secret", "token"];
@customElement("supervisor-app-config")
class SupervisorAppConfig extends LitElement {
class SupervisorAppConfig extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
@property({ attribute: false }) public addon!: HassioAddonDetails;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@state() private _configHasChanged = false;
@state() private _valid = true;
@state() private _canShowSchema = false;
@@ -351,9 +352,7 @@ class SupervisorAppConfig extends LitElement {
<div class="card-actions right">
<ha-progress-button
@click=${this._saveTapped}
.disabled=${this.disabled ||
!this._configHasChanged ||
!this._valid}
.disabled=${this.disabled || !this.isDirtyState || !this._valid}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -377,6 +376,7 @@ class SupervisorAppConfig extends LitElement {
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("addon")) {
this._options = { ...this.addon.options };
this._initDirtyTracking({ type: "deep" }, this.addon.options);
}
super.updated(changedProperties);
if (
@@ -415,11 +415,13 @@ class SupervisorAppConfig extends LitElement {
private _configChanged(ev): void {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true;
this._configHasChanged = true;
this._options = ev.detail.value;
this._updateDirtyState(ev.detail.value);
} else {
this._configHasChanged = true;
this._valid = ev.detail.isValid;
if (ev.detail.isValid) {
this._updateDirtyState(ev.detail.value);
}
}
}
@@ -450,7 +452,7 @@ class SupervisorAppConfig extends LitElement {
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
this._markDirtyStateClean();
const eventdata = {
success: true,
response: undefined,
@@ -469,7 +471,7 @@ class SupervisorAppConfig extends LitElement {
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (this.disabled || !this._configHasChanged || !this._valid) {
if (this.disabled || !this.isDirtyState || !this._valid) {
return;
}
@@ -499,7 +501,7 @@ class SupervisorAppConfig extends LitElement {
options,
});
this._configHasChanged = false;
this._markDirtyStateClean();
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -9,19 +9,44 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-switch";
import type { HaSwitch } from "../../../../../components/ha-switch";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import type {
AddonNetworkIsolationParams,
HassioAddonDetails,
HassioAddonSetOptionParams,
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { NetworkInterface } from "../../../../../data/hassio/network";
import { fetchNetworkInfo } from "../../../../../data/hassio/network";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
interface NetworkConfig {
ports: Record<string, number | null>;
isolation: AddonNetworkIsolationParams | null;
}
const isValidIpv4 = (address: string): boolean => {
const parts = address.split(".");
return (
parts.length === 4 &&
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
);
};
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends LitElement {
class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -30,19 +55,21 @@ class SupervisorAppNetwork extends LitElement {
@state() private _showOptional = false;
@state() private _configHasChanged = false;
@state() private _error?: string;
@state() private _config?: Record<string, any>;
@state() private _config?: NetworkConfig;
@state() private _isolationInterfaces?: NetworkInterface[];
protected render() {
if (!this._config) {
return nothing;
}
const hasHiddenOptions = Object.keys(this._config).find(
(entry) => this._config![entry] === null
const ports = this._config.ports;
const hasHiddenOptions = Object.keys(ports).find(
(entry) => ports[entry] === null
);
return html`
@@ -53,23 +80,29 @@ class SupervisorAppNetwork extends LitElement {
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.introduction"
)}
</p>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-form
.disabled=${this.disabled}
.data=${this._config}
@value-changed=${this._configChanged}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.schema=${this._createSchema(this._config, this._showOptional)}
></ha-form>
${this.addon.network
? html`
<p>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.introduction"
)}
</p>
<ha-form
.disabled=${this.disabled}
.data=${ports}
@value-changed=${this._configChanged}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.schema=${this._createSchema(ports, this._showOptional)}
></ha-form>
`
: nothing}
${this.addon.network_isolation_available
? this._renderIsolation()
: nothing}
</div>
${hasHiddenOptions
? html`<ha-formfield
@@ -98,7 +131,7 @@ class SupervisorAppNetwork extends LitElement {
</ha-progress-button>
<ha-progress-button
@click=${this._saveTapped}
.disabled=${!this._configHasChanged || this.disabled}
.disabled=${!this.isDirtyState || this.disabled}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -107,18 +140,129 @@ class SupervisorAppNetwork extends LitElement {
`;
}
private _renderIsolation() {
const isolation = this._config!.isolation;
return html`
<div class="isolation">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.title"
)}
>
<ha-switch
.checked=${isolation !== null}
.disabled=${this.disabled}
@change=${this._isolationToggled}
></ha-switch>
</ha-formfield>
<p class="secondary">
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.description"
)}
</p>
${isolation
? html`
<ha-select
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.interface"
)}
.value=${isolation.interface}
.disabled=${this.disabled}
.options=${(this._isolationInterfaces || []).map((iface) => ({
value: iface.interface,
label: iface.ipv4?.address?.length
? `${iface.interface} (${iface.ipv4.address.join(", ")})`
: iface.interface,
}))}
@selected=${this._isolationInterfaceChanged}
></ha-select>
<ha-input
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.ip_address"
)}
.hint=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.ip_address_helper"
)}
.value=${isolation.ipv4}
.disabled=${this.disabled}
@change=${this._isolationAddressChanged}
></ha-input>
${this.addon.network_isolation_mac
? html`
<p class="mac">
<span class="secondary">
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.mac_address"
)}
</span>
<code>${this.addon.network_isolation_mac}</code>
</p>
`
: nothing}
<ha-alert alert-type="info">
<ul>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.separate_device"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.ipv6"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.host_reachability"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.host_interfaces"
)}
</li>
</ul>
</ha-alert>
`
: nothing}
</div>
`;
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("addon")) {
this._setNetworkConfig();
if (
this.addon.network_isolation_available &&
this._isolationInterfaces === undefined
) {
this._loadIsolationInterfaces();
}
}
}
private async _loadIsolationInterfaces(): Promise<void> {
this._isolationInterfaces = [];
try {
const { interfaces } = await fetchNetworkInfo(this.hass);
this._isolationInterfaces = interfaces.filter(
(iface) => iface.network_isolation_capable
);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private _createSchema = memoizeOne(
(config: Record<string, number>, showOptional: boolean): HaFormSchema[] =>
(
ports: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
? Object.keys(ports)
: Object.keys(ports).filter((entry) => ports[entry] !== null)
).map((entry) => ({
name: entry,
selector: {
@@ -141,12 +285,58 @@ class SupervisorAppNetwork extends LitElement {
item.name;
private _setNetworkConfig(): void {
this._config = this.addon.network || {};
const config: NetworkConfig = {
ports: this.addon.network || {},
isolation: this.addon.network_isolation
? {
interface: this.addon.network_isolation.interface,
ipv4: this.addon.network_isolation.ipv4,
}
: null,
};
this._config = config;
this._initDirtyTracking({ type: "deep" }, config);
}
private async _configChanged(ev: CustomEvent): Promise<void> {
this._configHasChanged = true;
this._config = ev.detail.value;
private _configChanged(ev: CustomEvent): void {
this._config = { ...this._config!, ports: ev.detail.value };
this._updateDirtyState(this._config);
}
private _isolationToggled(ev: Event): void {
const enabled = (ev.target as HaSwitch).checked;
this._config = {
...this._config!,
isolation: enabled
? {
interface:
this.addon.network_isolation?.interface ||
this._isolationInterfaces?.[0]?.interface ||
"",
ipv4: this.addon.network_isolation?.ipv4 || "",
}
: null,
};
this._updateDirtyState(this._config);
}
private _isolationInterfaceChanged(ev: HaSelectSelectEvent): void {
this._config = {
...this._config!,
isolation: { ...this._config!.isolation!, interface: ev.detail.value },
};
this._updateDirtyState(this._config);
}
private _isolationAddressChanged(ev: Event): void {
this._config = {
...this._config!,
isolation: {
...this._config!.isolation!,
ipv4: (ev.target as HaInput).value || "",
},
};
this._updateDirtyState(this._config);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -155,13 +345,17 @@ class SupervisorAppNetwork extends LitElement {
}
const button = ev.currentTarget as any;
const data: HassioAddonSetOptionParams = {
network: null,
};
const data: HassioAddonSetOptionParams = {};
if (this.addon.network) {
data.network = null;
}
if (this.addon.network_isolation_available) {
data.network_isolation = null;
}
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
this._markDirtyStateClean();
const eventdata = {
success: true,
response: undefined,
@@ -188,25 +382,47 @@ class SupervisorAppNetwork extends LitElement {
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (!this._configHasChanged || this.disabled) {
if (!this.isDirtyState || this.disabled) {
return;
}
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
const { ports, isolation } = this._config!;
const data: HassioAddonSetOptionParams = {
network: networkconfiguration,
};
if (this.addon.network_isolation_available && isolation) {
if (!isolation.interface) {
this._error = this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.no_interface"
);
button.actionError();
return;
}
if (!isValidIpv4(isolation.ipv4)) {
this._error = this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.invalid_ip"
);
button.actionError();
return;
}
}
const data: HassioAddonSetOptionParams = {};
if (this.addon.network) {
const networkconfiguration: Record<string, number | null> = {};
Object.entries(ports).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
data.network = networkconfiguration;
}
if (this.addon.network_isolation_available) {
data.network_isolation = isolation;
}
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._configHasChanged = false;
this._markDirtyStateClean();
const eventdata = {
success: true,
response: undefined,
@@ -246,6 +462,36 @@ class SupervisorAppNetwork extends LitElement {
.show-optional {
padding: 16px;
}
ha-form + .isolation {
margin-top: var(--ha-space-6);
}
.isolation .secondary {
margin-top: var(--ha-space-1);
color: var(--secondary-text-color);
}
.isolation ha-select,
.isolation ha-input {
display: block;
width: 100%;
}
.isolation ha-input {
margin-top: var(--ha-space-4);
}
.isolation .mac {
margin-bottom: 0;
}
.isolation .mac code {
display: block;
margin-top: var(--ha-space-1);
}
.isolation ha-alert {
display: block;
margin-top: var(--ha-space-4);
}
.isolation ha-alert ul {
margin: 0;
padding-inline-start: var(--ha-space-4);
}
`,
];
}
@@ -832,6 +832,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</span>
<code slot="headline"> ${this._currentAddon.hostname} </code>
</ha-row-item>
${this._renderNetworkIsolationRows()}
${metrics.map(
(metric) => html`
<supervisor-app-metric
@@ -842,11 +843,46 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`
)}`
: nothing}
${this._currentAddon.version &&
this._currentAddon.state !== "started" &&
this._currentAddon.network_isolation
? html`<wa-divider></wa-divider>
${this._renderNetworkIsolationRows()}`
: nothing}
</div>
</ha-card>
`;
}
private _renderNetworkIsolationRows() {
const addon = this._currentAddon;
if (!addon.version || !addon.network_isolation) {
return nothing;
}
return html`
<ha-row-item>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.network_isolation_ip"
)}
</span>
<code slot="headline"> ${addon.network_isolation.ipv4} </code>
</ha-row-item>
${addon.network_isolation_mac
? html`
<ha-row-item>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.network_isolation_mac"
)}
</span>
<code slot="headline"> ${addon.network_isolation_mac} </code>
</ha-row-item>
`
: nothing}
`;
}
protected render(): TemplateResult {
return html`
${"protected" in this._currentAddon && !this._currentAddon.protected
@@ -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;
}
}
`;
}
@@ -3,6 +3,7 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
@@ -54,9 +55,20 @@ const SENSOR_DOMAINS = ["sensor"];
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
interface AreaFormState {
name: string;
aliases: string[];
labels: string[];
picture: string | null;
icon: string | null;
floor: string | null;
temperatureEntity: string | null;
humidityEntity: string | null;
}
@customElement("dialog-area-registry-detail")
class DialogAreaDetail
extends LitElement
extends DirtyStateProviderMixin<AreaFormState>()(LitElement)
implements HassDialog<AreaRegistryDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -116,9 +128,23 @@ class DialogAreaDetail
this._humidityEntity = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): AreaFormState {
return {
name: this._name,
aliases: this._aliases,
labels: this._labels,
picture: this._picture,
icon: this._icon,
floor: this._floor,
temperatureEntity: this._temperatureEntity,
humidityEntity: this._humidityEntity,
};
}
public closeDialog(): boolean {
this._open = false;
return true;
@@ -326,6 +352,8 @@ class DialogAreaDetail
if (processed.floor) {
this._floor = processed.floor;
}
this._updateDirtyState(this._currentState());
}
protected render() {
@@ -343,7 +371,7 @@ class DialogAreaDetail
header-title=${entry
? this.hass.localize("ui.panel.config.areas.editor.update_area")
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-suggest-with-ai-button
@@ -384,7 +412,9 @@ class DialogAreaDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting}
.disabled=${nameInvalid ||
this._submitting ||
(!!this._params?.entry && !this.isDirtyState)}
>
${entry
? this.hass.localize("ui.common.save")
@@ -418,36 +448,43 @@ class DialogAreaDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _floorChanged(ev) {
this._error = undefined;
this._floor = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _labelsChanged(ev) {
this._error = undefined;
this._labels = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -469,6 +506,7 @@ class DialogAreaDetail
} else {
await this._params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
@@ -41,6 +41,7 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
@@ -312,13 +313,27 @@ export default class HaAutomationActionRow extends LitElement {
.service=${this.action.action}
></ha-service-icon>
`
: html`
<ha-svg-icon
: type === "condition" &&
this.optionsInSidebar &&
(this.action as Condition).condition !== "trigger"
? html`<ha-automation-condition-live-test
id="condition-icon"
slot="leading-icon"
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
`}
.hass=${this.hass}
.condition=${this.action as Condition}
>
<ha-svg-icon
class="action-icon"
.path=${ACTION_ICONS[type]}
></ha-svg-icon>
</ha-automation-condition-live-test>`
: html`
<ha-svg-icon
slot="leading-icon"
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
`}
<h3 slot="header">
${capitalizeFirstLetter(
describeAction(
@@ -1,13 +1,18 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import type { BlueprintAutomationConfig } from "../../../data/automation";
import { fetchBlueprints } from "../../../data/blueprint";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
import { saveFabStyles } from "./styles";
@@ -19,7 +24,9 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
@property({ type: Boolean }) public saving = false;
@property({ type: Boolean }) public dirty = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get _config(): BlueprintAutomationConfig {
return this.config;
@@ -58,7 +65,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
<ha-button
slot="fab"
size="l"
class=${this.dirty ? "dirty" : ""}
class=${this._dirtyState?.isDirty ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._saveAutomation}
>
@@ -19,10 +19,7 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -38,12 +35,10 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/automation/ha-automation-row-live-test";
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
@@ -59,11 +54,7 @@ import type {
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import {
isCondition,
subscribeCondition,
testCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
@@ -155,11 +146,6 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _selected = false;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -172,8 +158,6 @@ export default class HaAutomationConditionRow extends LitElement {
private _testingTimeout?: number;
private _conditionUnsub?: Promise<UnsubscribeFunc>;
get selected() {
return this._selected;
}
@@ -212,27 +196,28 @@ export default class HaAutomationConditionRow extends LitElement {
);
return html`
<div id="condition-icon" class="icon-badge-wrapper" slot="leading-icon">
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`
: nothing}
</div>
${this.optionsInSidebar &&
this.condition.condition !== "trigger" &&
this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-condition-live-test
id="condition-icon"
slot="leading-icon"
.hass=${this.hass}
.condition=${this.condition}
>
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
</ha-automation-condition-live-test>`
: html`<div
id="condition-icon"
class="icon-badge-wrapper"
slot="leading-icon"
>
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
</div>`}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
@@ -593,11 +578,6 @@ export default class HaAutomationConditionRow extends LitElement {
></ha-automation-row-targets>`
);
public connectedCallback(): void {
super.connectedCallback();
this._subscribeCondition();
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
@@ -613,85 +593,11 @@ export default class HaAutomationConditionRow extends LitElement {
}
}
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("condition") &&
changedProps.get("condition") !== undefined
) {
this._resetSubscription();
this._debounceSubscribeCondition();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._debounceSubscribeCondition.cancel();
if (this._testingTimeout !== undefined) {
clearTimeout(this._testingTimeout);
}
this._resetSubscription();
}
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
}
}
private _debounceSubscribeCondition = debounce(
() => this._subscribeCondition(),
500
);
private async _subscribeCondition() {
this._resetSubscription();
if (!this.condition) {
return;
}
const conditionUnsub = subscribeCondition(
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
);
conditionUnsub.catch((err: any) => {
this._handleLiveTestError(err);
if (this._conditionUnsub === conditionUnsub) {
this._conditionUnsub = undefined;
}
});
this._conditionUnsub = conditionUnsub;
}
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
private _onValueChange(event: CustomEvent) {
+13 -5
View File
@@ -4,17 +4,25 @@ import { showToast } from "../../../util/toast";
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
// Editor elements that expose dirty tracking: the top-level automation/script
// editors via `isDirtyState`, and the manual editors via `dirty`.
interface DirtyStateElement extends HTMLElement {
isDirtyState?: boolean;
dirty?: boolean;
}
const isDirtyStateElement = (el: HTMLElement | null): el is DirtyStateElement =>
el !== null && ("isDirtyState" in el || "dirty" in el);
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
return isDirtyStateElement(el) && Boolean(el.isDirtyState);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
const holder = closestWithProperty(el, "dirty", false);
return isDirtyStateElement(holder) && Boolean(holder.dirty);
}
export function showEditorToast(
@@ -146,6 +146,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
currentConfig: () => this.config!,
});
public override get isDirtyState(): boolean {
return super.isDirtyState || !!this.yamlErrors;
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
@@ -421,7 +425,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.dirty=${this.dirty}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
></blueprint-automation-editor>
@@ -434,7 +437,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.stateObj=${stateObj}
.config=${this.config}
.disabled=${this.readOnly}
.dirty=${this.dirty}
.saving=${this.saving}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@@ -554,7 +556,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<ha-button
slot="fab"
size="l"
class=${this.dirty ? "dirty" : ""}
class=${this.isDirtyState ? "dirty" : ""}
.disabled=${this.saving}
@click=${this._handleSaveAutomation}
>
@@ -602,7 +604,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
!this.entityId
) {
const initData = getAutomationEditorInitData();
this.dirty = !!initData;
let baseConfig: Partial<AutomationConfig> = { description: "" };
if (!initData || !("use_blueprint" in initData)) {
baseConfig = {
@@ -617,6 +618,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
...baseConfig,
...(initData ? normalizeAutomationConfig(initData) : initData),
} as AutomationConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
this._updateDirtyState(this.config);
this.currentEntityId = undefined;
this.readOnly = false;
}
@@ -624,10 +627,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (changedProps.has("entityId") && this.entityId) {
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._checkValidation();
});
this.currentEntityId = this.entityId;
this.dirty = false;
this.readOnly = true;
}
@@ -690,7 +693,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (this.readOnly) {
return;
}
this.dirty = true;
this._updateDirtyState(this.config);
this.errors = undefined;
}
@@ -762,7 +765,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
this.dirty = true;
if (!ev.detail.isValid) {
this.yamlErrors = ev.detail.errorMsg;
return;
@@ -772,11 +774,12 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
id: this.config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._updateDirtyState(this.config!);
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
if (!this.dirty) {
if (!this.isDirtyState) {
return true;
}
@@ -787,7 +790,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this.dirty = true;
this._updateDirtyState(this.config);
this.requestUpdate();
const id = this.automationId || String(Date.now());
@@ -901,7 +904,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this.dirty = true;
this._updateDirtyState(this.config);
this.requestUpdate();
resolve(true);
},
@@ -918,7 +921,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
updateConfig: (config) => {
this.config = config;
this.dirty = true;
this._updateDirtyState(config);
this.requestUpdate();
resolve();
},
@@ -1009,7 +1012,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
}
this.dirty = false;
this._markDirtyStateClean();
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -1068,7 +1071,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this.dirty = true;
this._updateDirtyState(this.config);
}
private _undo() {
@@ -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<TConfig>()(
superClass
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -102,8 +105,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry?: EntityRegistryEntry[];
@state() protected dirty = false;
@state() protected errors?: string;
@state() protected yamlErrors?: string;
@@ -217,7 +218,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected takeControlSave() {
this.readOnly = false;
this.dirty = true;
// Force dirty: set baseline to null so current config always differs
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
this._updateDirtyState(this.config!);
this.blueprintConfig = undefined;
}
@@ -237,10 +240,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
};
protected get isDirty() {
return this.dirty;
}
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
}
@@ -259,9 +258,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
this.dirty = false;
this.readOnly = false;
this.config = hooks.normalizeConfig(config);
this._initDirtyTracking({ type: "deep" }, this.config);
hooks.checkValidation();
} catch (err: any) {
if (err.status_code !== 404) {
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
@@ -19,6 +20,10 @@ import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type {
Constructor,
HomeAssistant,
@@ -46,7 +51,13 @@ export const ManualEditorMixin = <TConfig>(
@property({ attribute: false }) public config!: TConfig;
@property({ attribute: false }) public dirty = false;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext;
protected get dirty(): boolean {
return this._dirtyState?.isDirty ?? false;
}
@state() protected pastedConfig?: TConfig;
@@ -15,6 +15,7 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type {
BackupConfig,
BackupMutableConfig,
@@ -82,7 +83,10 @@ const RECOMMENDED_CONFIG: BackupConfig = {
};
@customElement("ha-dialog-backup-onboarding")
class DialogBackupOnboarding extends LitElement implements HassDialog {
class DialogBackupOnboarding
extends DirtyStateProviderMixin<BackupConfig>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -115,6 +119,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._config!);
}
public closeDialog() {
@@ -169,6 +174,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
try {
await this._save(true);
this._params?.submit!(true);
this._markDirtyStateClean();
this.closeDialog();
} catch (err) {
// eslint-disable-next-line no-console
@@ -214,7 +220,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
<ha-dialog
.open=${this._open}
header-title=${this._stepTitle}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${isFirstStep
@@ -293,6 +299,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
password: this._config.create_backup.password,
},
};
this._updateDirtyState(this._config);
this._done();
}
@@ -515,6 +522,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
include_addons: data.include_addons || null,
},
};
this._updateDirtyState(this._config);
}
private _scheduleChanged(ev) {
@@ -524,6 +532,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
schedule: value.schedule,
retention: value.retention,
};
this._updateDirtyState(this._config);
}
private _agentsConfigChanged(ev) {
@@ -535,6 +544,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
agent_ids: agents,
},
};
this._updateDirtyState(this._config);
}
static get styles(): CSSResultGroup {
@@ -12,13 +12,17 @@ import {
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
class DialogDownloadDecryptedBackup
extends DirtyStateProviderMixin<string>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -32,6 +36,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._open = true;
this._params = params;
this._initDirtyTracking({ type: "shallow" }, "");
}
public closeDialog() {
@@ -60,7 +65,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<p>
@@ -105,7 +110,11 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this.isDirtyState}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
@@ -136,6 +145,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
this._agentId,
this._encryptionKey
);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
@@ -155,6 +165,7 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
this._updateDirtyState(this._encryptionKey);
}
private get _agentId() {
@@ -28,6 +28,7 @@ import {
fetchBackupConfig,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "../components/config/ha-backup-config-data";
@@ -59,7 +60,10 @@ const STEPS = ["data", "sync"] as const;
const DISALLOWED_AGENTS_NO_HA = [CLOUD_AGENT];
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup extends LitElement implements HassDialog {
class DialogGenerateBackup
extends DirtyStateProviderMixin<FormData>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _step?: "data" | "sync";
@@ -79,6 +83,8 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
this._formData = INITIAL_DATA;
this._params = _params;
this._open = true;
this._initDirtyTracking({ type: "deep" }, INITIAL_DATA);
this._updateDirtyState(this._formData);
this._fetchAgents();
this._fetchBackupConfig();
@@ -160,6 +166,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
agents_mode: "custom",
agent_ids: filteredAgents,
};
this._updateDirtyState(this._formData);
}
}
}
@@ -180,7 +187,11 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
const selectedAgents = this._formData.agent_ids;
return html`
<ha-dialog .open=${this._open} @closed=${this._dialogClosed}>
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dialog-header slot="header">
${isFirstStep
? html`
@@ -276,6 +287,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
data,
};
this._updateDirtyState(this._formData);
}
private _renderSync() {
@@ -370,6 +382,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
agents_mode: value,
};
this._updateDirtyState(this._formData);
}
private _agentsChanged(ev) {
@@ -377,6 +390,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
agent_ids: ev.detail.value,
};
this._updateDirtyState(this._formData);
}
private _nameChanged(ev: InputEvent) {
@@ -384,6 +398,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
...this._formData!,
name: (ev.target as HaInput).value ?? "",
};
this._updateDirtyState(this._formData);
}
private _disabledAgentIds() {
@@ -429,6 +444,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
}
this._params!.submit?.(params);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -13,6 +13,7 @@ import type {
} from "../../../../components/ha-form/types";
import { extractApiErrorMessage } from "../../../../data/hassio/common";
import { changeMountOptions } from "../../../../data/supervisor/mounts";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LocalBackupLocationDialogParams } from "./show-dialog-local-backup-location";
@@ -25,8 +26,14 @@ const SCHEMA = [
},
] as const satisfies HaFormSchema[];
interface LocalBackupLocationFormState {
default_backup_mount: string | null | undefined;
}
@customElement("dialog-local-backup-location")
class LocalBackupLocationDialog extends LitElement {
class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocationFormState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: LocalBackupLocationDialogParams;
@@ -44,6 +51,10 @@ class LocalBackupLocationDialog extends LitElement {
): Promise<void> {
this._dialogParams = dialogParams;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{ default_backup_mount: undefined }
);
}
public closeDialog(): void {
@@ -68,7 +79,7 @@ class LocalBackupLocationDialog extends LitElement {
header-title=${this.hass.localize(
`ui.panel.config.backup.dialogs.local_backup_location.title`
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error
@@ -102,7 +113,7 @@ class LocalBackupLocationDialog extends LitElement {
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._waiting || !this._data}
.disabled=${this._waiting || !this.isDirtyState}
slot="primaryAction"
@click=${this._changeMount}
>
@@ -125,6 +136,9 @@ class LocalBackupLocationDialog extends LitElement {
this._data = {
default_backup_mount: newLocation === "/backup" ? null : newLocation,
};
this._updateDirtyState({
default_backup_mount: this._data.default_backup_mount,
});
}
private async _changeMount() {
@@ -140,6 +154,7 @@ class LocalBackupLocationDialog extends LitElement {
this._waiting = false;
return;
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -52,7 +52,6 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
@@ -21,6 +21,7 @@ import {
type BackupUploadFileFormData,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@@ -28,7 +29,7 @@ import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
@customElement("ha-dialog-upload-backup")
export class DialogUploadBackup
extends LitElement
extends DirtyStateProviderMixin<BackupUploadFileFormData>()(LitElement)
implements HassDialog<UploadBackupDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -47,6 +48,8 @@ export class DialogUploadBackup
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, INITIAL_UPLOAD_FORM_DATA);
this._updateDirtyState(this._formData);
}
private _dialogClosed() {
@@ -64,10 +67,6 @@ export class DialogUploadBackup
return true;
}
private _formValid() {
return this._formData?.file !== undefined;
}
protected render() {
if (!this._params || !this._formData) {
return nothing;
@@ -79,7 +78,7 @@ export class DialogUploadBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
?prevent-scrim-close=${this._uploading}
.preventScrimClose=${this.isDirtyState || this._uploading}
@closed=${this._dialogClosed}
>
${this._error
@@ -112,7 +111,7 @@ export class DialogUploadBackup
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
.disabled=${!this.isDirtyState || this._uploading}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action"
@@ -131,11 +130,13 @@ export class DialogUploadBackup
...this._formData!,
file,
};
this._updateDirtyState(this._formData);
}
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._updateDirtyState(this._formData);
}
private async _upload() {
@@ -161,6 +162,7 @@ export class DialogUploadBackup
try {
await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -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 =
@@ -45,6 +45,7 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
@@ -347,13 +348,22 @@ class HaConfigSectionUpdates extends LitElement {
return;
}
try {
await installUpdates(this.hass, entityIds);
await installUpdates(this.hass, entityIds, false);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.updates.update_all_failed"),
text: extractApiErrorMessage(err),
warning: true,
});
let message = extractApiErrorMessage(err);
// The backend error embeds the raw entity_id; swap in the update's name.
for (const entityId of entityIds) {
const stateObj = this.hass.states[entityId] as UpdateEntity | undefined;
if (stateObj && message.includes(entityId)) {
message = message.replaceAll(
entityId,
stateObj.attributes.title ||
stateObj.attributes.friendly_name ||
entityId
);
}
}
showToast(this, { message, duration: 10000, dismissable: true });
}
}
@@ -17,7 +17,6 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-menu-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tip";
import "../../../components/ha-tooltip";
@@ -236,7 +235,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button slot="navigationIcon"></ha-menu-button>
<div slot="title">${this.hass.localize("panel.config")}</div>
<ha-icon-button

Some files were not shown because too many files have changed in this diff Show More