Compare commits

...

349 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
karwosts d45b5d20a2 Fix hui-editor search (#52453) 2026-06-05 20:27:21 +02:00
elcaptain ad593a6733 added Brand Personality (#52451)
* added Brand Personality

as a result of https://github.com/OpenHomeFoundation/roadmap/issues/124

* fixed formatting
2026-06-05 20:26:15 +02:00
Aidan Timson 18c084b4da Add maintenance my redirect (#52442)
Add maintenance My redirect
2026-06-05 20:21:43 +02:00
renovate[bot] d761a68bee Update yarn monorepo to v4.16.0 (#52449) 2026-06-05 14:52:54 +01:00
Petar Petrov 937abc7e86 Use context instead of hass in domain state display components (#52448) 2026-06-05 14:11:58 +01:00
Petar Petrov 6d3979be17 Use context instead of hass in cover and valve control rows (#52446) 2026-06-05 14:11:01 +01:00
Jan-Philipp Benecke ef44a2d682 Refactor developer tools to use top bar component (#52316)
* Refactor developer tools to use top bar component

* Format
2026-06-05 15:43:03 +03:00
renovate[bot] 378c5a3c9d Update rspack monorepo to v2.0.6 (#52447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 15:31:29 +03:00
renovate[bot] aaea886d51 Update dependency idb-keyval to v6.2.5 (#52445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 15:30:56 +03:00
Petar Petrov a040dca639 Use consumeLocalize instead of hass in localize-only leaf components (#52443) 2026-06-05 15:13:09 +03:00
Jan-Philipp Benecke a59f33d54c Use context instead of hass in ha-menu-button (#52433) 2026-06-05 09:24:57 +01:00
Petar Petrov 4326e1880b Document hass-to-context migration in frontend instructions (#52421) 2026-06-05 08:19:43 +01:00
Petar Petrov d8bd82d3bb Add hide/show cards to the energy dashboard (#52362) 2026-06-05 08:13:20 +01:00
Aidan Timson ca9879cb4e Fixes for more info script service control area (#52417)
* Use space tokens

* Fix overflow issue on entities

* Enforce narrow mode on target selectors

* Add action to full width selectors

* Function signature typing
2026-06-05 09:03:49 +03:00
Aidan Timson 89bd78463b Replace hass with lazy context in integration filters and update areas (#52390)
Replace hass with lazy context in settings areas
2026-06-05 09:01:51 +03:00
Aidan Timson 6350417c52 Add related context to scene editor (#52410) 2026-06-05 08:52:06 +03:00
renovate[bot] 7f6f29629f Update dependency barcode-detector to v3.2.0 (#52425)
* Update dependency barcode-detector to v3.2.0

* bump license version

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-05 05:35:28 +00:00
Jan-Philipp Benecke b224bd7077 Patch tinykeys v4 to make it compatible with older iOS versions (#52420)
* Downgrade tinykeys to 3.1.0 to make it compatible with older iOS versions

* Patch tinykeys v4

* Remove umd patch
2026-06-05 08:21:13 +03:00
renovate[bot] 6338d0ea4e Update dependency fuse.js to v7.4.1 (#52432)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 08:09:59 +03:00
renovate[bot] c21ffeacad Update typescript-eslint monorepo to v8.60.1 (#52428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 08:05:11 +03:00
renovate[bot] 69b33ff015 Update dependency tar to v7.5.16 (#52423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 20:22:43 +02:00
Bram Kragten 6e2968671b Check if package is available on pypi instead of fixed wait (#52419) 2026-06-04 17:12:00 +02:00
Paul Bottein 453d412549 Add customize toggle to media player source and sound mode feature editors (#52414) 2026-06-04 16:09:27 +01:00
renovate[bot] 4a3eea5d2b Update tsparticles to v4.1.2 (#52418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 17:08:01 +02:00
Bram Kragten ee755ff58a Use zone location as location of person if no gps is available (#52415) 2026-06-04 17:49:23 +03:00
Aidan Timson 635a6442b4 Update agents on button size after #52391 (#52411)
* Update agents button size after #52391

* Add updated sizes
2026-06-04 17:41:37 +03:00
Paul Bottein 3608156e83 Show media player playback controls as disabled instead of hiding them (#52370)
* Show media player playback controls as disabled instead of hiding them

* Add on/off power by default
2026-06-04 15:55:05 +03:00
Bram Kragten 8529980bdd Show template errors in traces (#52412) 2026-06-04 13:54:08 +01:00
Paul Bottein 9ff508259f Migrate deprecated Web Awesome size values to short form (s/m/l) (#52391) 2026-06-04 13:32:33 +01:00
Petar Petrov 1ec4dc6c79 Trim redundancy in AI coding instructions (#52409) 2026-06-04 13:30:13 +01:00
Aidan Timson 39c88e573d Match the card style of apps repo to installed (#52407) 2026-06-04 13:49:13 +02:00
renovate[bot] 99eb752a68 Update vitest monorepo to v4.1.8 (#52405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 11:39:55 +02:00
Jan-Philipp Benecke 9cc63c1f53 Use context instead of hass property in ha-area-picker (#52394)
* Use context instead of hass property in ha-area-picker

* Use state() on _areas
2026-06-04 11:42:54 +03:00
Aidan Timson d2188f600f Add related context event to trace pages (#52392)
* Add trace related context

* Review
2026-06-04 10:47:19 +03:00
Petar Petrov d69d4c592d Update AI coding instructions to match current frontend code (#52403)
Update AI coding instructions to match current frontend APIs

Fix outdated references in .github/copilot-instructions.md (symlinked as
CLAUDE.md and AGENTS.md): showAlertDialog import path, ESLint flat config,
ha-dialog width presets, ha-alert slots/props, ha-button variant/appearance
guidance, Web Awesome direction, and the querySelector pitfall.
2026-06-04 09:46:21 +02:00
Aidan Timson a97df0409c Fix editor context (#52393) 2026-06-04 09:24:05 +03:00
Aidan Timson 468756bd2f Add related context to area page (#52383) 2026-06-04 08:30:02 +03:00
ildar170975 cc43caa87b hui-card-picker: fix a path to "content" (#52398)
fix a path to "content"
2026-06-04 08:29:00 +03:00
renovate[bot] 1fb3efadfa Update dependency js-yaml to v4.2.0 (#52401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 08:02:56 +03:00
Wendelin 69599352a3 Introduce list-virtualized (#52354)
* Fix keyboard nav and focus

* Simplify

* use virtualized list in zha

* update jsdocs

* Fix ha-domain-integration

* Update docs and clean up

* fix

* review

* Add translation

---------

Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-06-04 07:58:59 +03:00
Simon Lamon 5c0f2feac1 Remove explicit netlify version (#52386) 2026-06-03 20:50:55 +02:00
renovate[bot] 033d035b18 Update dependency lint-staged to v17.0.7 (#52389) 2026-06-03 12:03:23 +01:00
Paul Bottein 04a986cd2c Restore search field autofocus in card and badge pickers (#52387) 2026-06-03 10:49:50 +01:00
Wendelin 5b09b1475d Landingpage download progress (#52359)
* Simplify and improve landingpage

* add core download progress

* reduce to 2 seconds

* Use round to display full integer as progress percentage

* Use find to get the job object

* Don't show progress label when progress is at 0

Before download starts, progress is at 0. At this point we may trying
to reach a server (and error out), so we aren't really in downloading
phase just yet. Simply treat 0 as "not started" and hide the progress
label until we have a real progress value.

---------

Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-06-03 12:07:01 +03:00
Aidan Timson b1b9ac23cf Fix automation building block action icon style (#52382) 2026-06-03 10:21:48 +02:00
Paul Bottein aa19ca91a3 Add registryDependencies to strategies for selective regeneration (#30102) 2026-06-03 08:56:26 +02:00
Jan-Philipp Benecke 1388aa56ea Use consumeLocalize instead of hass in ha-icon-button-arrow-prev (#52377)
Use context in ha-icon-button-arrow-prev
2026-06-03 08:13:44 +03:00
renovate[bot] 6ca7ac1ca5 Update dependency fuse.js to v7.4.0 (#52379)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 08:13:03 +03:00
renovate[bot] d4380248c2 Update tsparticles to v4.1.1 (#52380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 08:12:39 +03:00
Jan-Philipp Benecke 14617aaf3c Fix hass-tabs-subpage narrow toggle (#52375) 2026-06-02 19:53:31 +02:00
Bram Kragten 42e1051d9c Add tags in app store too, plus show if addon is installed already (#52373) 2026-06-02 18:48:09 +02:00
Marcin Bauer cfe30114f0 Move live-test indicator to badge on condition icon (#52352)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-06-02 14:19:35 +00:00
Petar Petrov 288c03c248 Fix raw div tag showing in Sankey chart tooltips (#52365)
Fix raw div tag showing in sankey chart tooltips
2026-06-02 16:12:38 +02:00
renovate[bot] 8cd9a5adf6 Update dependency lint-staged to v17.0.6 (#52363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 13:17:09 +00:00
Bram Kragten 4d3437b491 Matter add device: change how main entity is found (#52361)
Don't search for a entity based on main entity but use entity_category
2026-06-02 15:13:07 +02:00
Bram Kragten ceb51714be Migrate trigger behavior (#52360)
* Migrate trigger behavior

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-06-02 15:00:34 +02:00
karwosts d2c868f904 Avoid double collection fetch when loading energy dashboard strategy (#52351)
* Avoid double collection fetch when loading energy

* Load the now collection in strategy if we're heading to /now
2026-06-02 12:19:05 +03:00
Copilot 9f34de5de6 Update dialog dismissal guideline (#52333)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-02 12:07:19 +03:00
Jan-Philipp Benecke 6c9452aa5a Use context instead passing narrow down to hass-tabs-subpage (#52345)
Use context instead passing narrow down in hass-tabs-subpage
2026-06-02 12:06:47 +03:00
Aidan Timson 2cf79853aa Improve messaging and consolidate add to dialogs (#52330) 2026-06-02 10:32:56 +02:00
Petar Petrov 6152812138 Skip in-progress updates when triggering Update all (#52353)
Filter out updates that are already installing before calling the
update.install service, and disable a group's Update all button when
every update in it is already in progress.
2026-06-02 09:11:26 +02:00
Aidan Timson 5540a6c1ff Use options object instead of multiple undefined params for getAreas, getDevices, getEntities (#52342) 2026-06-02 08:52:02 +02:00
renovate[bot] e04297f2bd Update dependency date-fns to v4.4.0 (#52350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 08:20:39 +03:00
renovate[bot] e89f76bbbb Update dependency eslint to v10.4.1 (#52349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 08:20:07 +03:00
renovate[bot] 319ba3940e Update tsparticles to v4.1.0 (#52344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 16:27:46 +00:00
Paul Bottein b9920065a2 Fix vacuum and lawn mower features not showing default buttons (#52343) 2026-06-01 18:13:38 +02:00
Petar Petrov 3bb5201d41 Potential fix for code scanning alert no. 3: Incomplete string escaping or encoding (#52332)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-01 18:12:13 +02:00
Bram Kragten a0648b85ff Allow to set refresh url while dialog is open, use for matter device (#52341)
Allow to set refresh dialog while dialog is open, use for matter device
2026-06-01 18:11:23 +02:00
Petar Petrov 54f901c7c9 Group pending updates by category on the updates page (#52215) 2026-06-01 17:23:56 +02:00
Wendelin 2483a917f8 Fix picker default popover-placement (#52336) 2026-06-01 14:55:48 +01:00
renovate[bot] d9cae08f53 Update dependency @rspack/dev-server to v2.0.3 (#52338) 2026-06-01 14:55:47 +02:00
Aidan Timson 106b35d6cf Create related context, add automation element dialog search weighting for area (#52280)
* Create shared related context for quick bar and add automation element

* Provider mixin and dont pass sets when context can be used

* Direct use consume

* Move context provider into class and use in context mixin

* Cleanup

* Match signatures with lazy context provider

* Fix order of operations

* Typing

* Fix popstate on dialog launch/close

* Typing improvement
2026-06-01 15:49:50 +03:00
Wendelin f12d305688 App-Info: Hide app title on narrow (#52337)
Hide app title on narrow
2026-06-01 15:36:48 +03:00
Paul Bottein d2326b4f62 Respect backend order for floors and areas in entity tree (#52329) 2026-06-01 15:02:01 +03:00
Jan-Philipp Benecke ea9424053a Introduce narrow viewport context (#52320)
* Introduce narrow viewport context

* Format

* Revert sidebar narrow context

* Discard changes to src/components/ha-sidebar.ts
2026-06-01 14:58:57 +03:00
Petar Petrov 70ffef8807 Remove unused dependencies (#52328)
Drop dependencies that are no longer referenced anywhere in the codebase:

- @material/mwc-base: declared directly but never imported; still pulled
  in transitively by the other @material/mwc-* packages, so the explicit
  declaration was redundant.
- @types/mocha: the test runner is Vitest (test files import describe/it/
  expect from "vitest"); no Mocha globals or namespace are used.
- @types/webspeechapi: no Web Speech API usage in the codebase, and the
  modern TypeScript lib.dom already ships these definitions.
- @types/babel__plugin-transform-runtime: the plugin is only referenced by
  string name in the Babel config, never imported as a typed module, so the
  type stub is unused.

Co-authored-by: MindFreeze <noreply@anthropic.com>
2026-06-01 08:41:04 +02:00
Simon Lamon a32169f300 Ignore location in description (#52297) 2026-06-01 09:31:01 +03:00
Simon Lamon b508760d24 Show all counter actions if none specified (#52317)
Show all actions if none specified
2026-06-01 08:12:37 +03:00
Simon Lamon 541cab83de Fix lock workflow (#52318) 2026-06-01 08:11:45 +03:00
renovate[bot] 8e8f2bfa4c Update dependency @bundle-stats/plugin-webpack-filter to v4.22.2 (#52323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 08:11:09 +03:00
George Caliment bafe21ab48 Fixed label assignment dropdown scroll and width on mobile screens (#52326)
* Fixed ha-config-entities label assignment dropdown scroll and width on mobile screens

* Made ha-label handle text ellipsis by itself
2026-06-01 08:10:42 +03:00
George Caliment ee56d7d003 Fixed filter flex direction on mobile + removed unused classes (#52327)
* Fixed filter flex direction on mobile + removed unused classes

* Removed hard-coded height to fill all viewport
2026-06-01 08:09:42 +03:00
renovate[bot] 486b6bb561 Update dependency @rsdoctor/rspack-plugin to v1.5.12 (#52319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 11:47:39 +02:00
karwosts 9a9ceaebf2 Fix behavior for move view left/right (#52300) 2026-05-31 09:41:36 +02:00
Petar Petrov ff5bbf46ae Migrate date/time selectors to internationalization locale context (#52265)
Consume internationalizationContext for locale in ha-selector-date,
ha-selector-datetime, ha-selector-time, ha-selector-button-toggle, and
ha-selector-select (partial: hass kept where passed to children).
2026-05-31 09:16:43 +02:00
Petar Petrov 47fb4a2def Migrate hui-badge-edit-mode and hui-card-edit-mode to localize context (#52264)
* Migrate hui-badge-edit-mode and hui-card-edit-mode to localize context

Replace this.hass.localize with @consumeLocalize(); remove hass prop and
caller .hass bindings from Lovelace edit-mode overlays.

* Migrate hui-view-badges to localize context
2026-05-31 09:15:46 +02:00
Petar Petrov 0e716e5078 Migrate ha-person-badge and ha-user-badge to connection context (#52263)
* Migrate avatar badges off hass property (Group K)

Consume connectionContext for hassUrl and statesContext plus
consumeEntityState for the user badge person entity picture.
Remove .hass bindings from callers.

* Migrate ha-person-badge and ha-user-badge to connection context

Use connectionContext for hassUrl; ha-user-badge also consumes entity state
for person picture lookup.

* Fix ha-user-badge updates and restore hass binding

Use PropertyValues.has() to detect _states changes. Restore .hass bindings on
ha-user-picker callers and ha-generic-picker, which still require hass.

* Fix type error and restore hass on person subpage

- Type willUpdate with PropertyValues so changedProps.has("_states")
  type-checks (matches the other states-context consumers); fixes the
  failing lint:types CI check
- Restore .hass on hass-tabs-subpage in ha-config-person, which still
  requires hass and was crashing the person config page
- Drop now-dead .hass bindings on ha-user-badge in ha-user-picker
- Only rescan for the person entity when the user changes or the tracked
  entity is missing, instead of on every state update
2026-05-31 09:15:40 +02:00
dependabot[bot] 9e9fdfbad6 Bump home-assistant/actions from f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b to 868e6cb4607727d764341a158d98872cd63fa658 (#52312)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b to 868e6cb4607727d764341a158d98872cd63fa658.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b...868e6cb4607727d764341a158d98872cd63fa658)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: 868e6cb4607727d764341a158d98872cd63fa658
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 09:13:37 +02:00
dependabot[bot] 5ebdb99ba7 Bump actions/stale from 10.2.0 to 10.3.0 (#52313)
Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 09:13:22 +02:00
dependabot[bot] 1ca454cf02 Bump github/codeql-action from 4.35.5 to 4.36.0 (#52315)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.5 to 4.36.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9e0d7b8d25671d64c341c19c0152d693099fb5ba...7211b7c8077ea37d8641b6271f6a365a22a5fbfa)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 09:13:20 +02:00
renovate[bot] 859d23c187 Update formatjs monorepo (#52309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 09:12:47 +02:00
renovate[bot] f9d205defe Update dependency @rspack/core to v2.0.5 (#52314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 09:12:44 +02:00
dependabot[bot] 00cc4e2a5a Bump dessant/lock-threads from 6.0.0 to 6.0.2 (#52311)
Bumps [dessant/lock-threads](https://github.com/dessant/lock-threads) from 6.0.0 to 6.0.2.
- [Release notes](https://github.com/dessant/lock-threads/releases)
- [Changelog](https://github.com/dessant/lock-threads/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dessant/lock-threads/compare/7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7...89ae32b08ed1a541efecbab17912962a5e38981c)

---
updated-dependencies:
- dependency-name: dessant/lock-threads
  dependency-version: 6.0.2
  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-05-31 08:44:20 +02:00
renovate[bot] 6571feb556 Update dependency terser-webpack-plugin to v5.6.1 (#52307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 18:54:29 +00:00
renovate[bot] 4150bc0806 Update dependency license-checker-rseidelsohn to v5 (#52304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 20:50:36 +02:00
renovate[bot] 958e3f2575 Update dependency @swc/helpers to v0.5.23 (#52303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 20:47:53 +02:00
Jan-Philipp Benecke 3d3292e2ad Use right token for topbar shadow transition (#52306) 2026-05-30 20:47:26 +02:00
karwosts 75b9fb2e34 Fix untracked legend in detail graph card (#52299) 2026-05-30 07:53:40 +03:00
Jan-Philipp Benecke 38f0ce306b Add box-shadow transition to top app bar (#52292) 2026-05-29 18:01:44 +02:00
Petar Petrov 1ffd19e20b Make battery dialog charge/discharge order consistent (#52295)
Order the energy fields as discharge then charge to match the power
fields in the energy panel battery settings dialog.

Co-authored-by: MindFreeze <noreply@anthropic.com>
2026-05-29 18:01:14 +02:00
Aidan Timson 9a216cae46 Add cover and valve favorite positions to suggestions (#52273) 2026-05-29 08:33:23 +03:00
karwosts 41e6408508 Fix missing location data in calendar (#52291) 2026-05-29 08:32:15 +03:00
renovate[bot] 97e85bc06f Update dependency typescript-eslint to v8.60.0 (#52290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 08:06:38 +03:00
Petar Petrov 5f2ad7fa01 Migrate hui-buttons-base and ha-selector-attribute to states context (partial hass) (#52262) 2026-05-28 16:16:10 +02:00
ildar170975 7b6b70023b Statistics graph card editor: add sub editor (#52182)
* add canEdit

* add canEdit

* add subEditor

* linter

* linter

* linter

* linter

* Remove div

* Update src/components/entity/ha-statistic-picker.ts

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

* Update src/components/entity/ha-statistic-picker.ts

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

* Update ha-statistic-picker.ts

* Update ha-statistic-picker.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-28 17:13:20 +03:00
Paul Bottein 256a06e35f Preserve PNG transparency on area pictures (#52282) 2026-05-28 16:08:16 +02:00
Paul Bottein 4e26c05ac6 Don't lowercase translated default action label (#52283) 2026-05-28 13:47:56 +00:00
Paul Bottein 04ee8ac415 Fix sun condition Between description showing reversed values (#52279) 2026-05-28 13:46:56 +00:00
Petar Petrov 63e144309c Migrate ha-selector choose/period/file/selector to localize context (#52266) 2026-05-28 15:32:21 +02:00
Petar Petrov 77039cda8e Migrate automation icon components to config and connection context (#52261) 2026-05-28 15:22:06 +02:00
Petar Petrov ab5b4ed792 Drop unused hass prop from ha-selector-boolean, ha-selector-duration, ha-sunburst-chart (#52253)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-28 13:21:47 +00:00
Petar Petrov a08905cd31 Migrate ha-relative-time and ha-absolute-time to localize/locale/config context (#52259)
* Migrate ha-relative-time and ha-absolute-time off hass property

Consume localize, locale, and config via Lit context so time primitives
only rerender when i18n or config slices change, and drop obsolete .hass
bindings from callers.

* Consume full i18n context in time display components

Use internationalizationContext directly for both localize and locale in
ha-relative-time and ha-absolute-time, avoiding mixed consumption patterns.
2026-05-28 16:16:11 +03:00
Petar Petrov a35349196f Migrate trace logbook components to localize context (#52260) 2026-05-28 15:14:44 +02:00
Petar Petrov dbdfdedd74 Migrate energy total badges to states/locale/localize context (partial hass) (#52255) 2026-05-28 15:01:31 +02:00
Petar Petrov a5c8547b2b Migrate ha-filter-blueprints and ha-filter-voice-assistants to localize context (#52252) 2026-05-28 14:59:13 +02:00
Jan-Philipp Benecke e373689a37 Refactor climate panel to use top bar component (#52245)
* Refactor climate panel to use top bar component

* Remove calc

* Remove

* Remove
2026-05-28 15:48:06 +03:00
Jan-Philipp Benecke 5edcdb8977 Refactor light panel to use top bar component (#52246)
* Refactor light panel to use top bar component

* Remove

* Remove

* Remove
2026-05-28 15:47:41 +03:00
Wendelin 26b8921e8c Fix automation behavior img file names (#52247)
fix behavior img names
2026-05-28 14:33:55 +02:00
Petar Petrov b8c201b6d3 Render echarts tooltips with Lit templates (#52235)
* Render echarts tooltips with Lit templates

Replace raw HTML string interpolation in echarts tooltip formatters with Lit templates so user-controlled fields (entity friendly_name, device names, node labels) are auto-escaped instead of relying on per-string filterXSS. ha-chart-base now wraps any function tooltip.formatter into a stable per-formatter container and handles Lit TemplateResult / nothing / null returns; the public HaECOption type lets charts express Lit-returning formatters without per-callsite casts.

* Simplify

* Refactor _getSeries

* Small fix

* Fix merge mistake

* Marker component and wrapper test
2026-05-28 14:27:47 +02:00
Wendelin 4a6c23c93e Fix automation add TCA paste (#52276)
Fix automation add paste
2026-05-28 14:22:52 +02:00
Wendelin e2712cb0b0 Fix row target count flickering, keyboard nav, type device (#52236)
* Fix row target count flickering

* Add noninteractive for device, fix keyboard nav

* Noninteractive action, conditon

* Remove unsued hass

* invert noninteractive
2026-05-28 14:14:26 +02:00
renovate[bot] db52cd0d8e Update babel monorepo to v7.29.7 (#52277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-28 15:01:16 +03:00
Wendelin 4891783c86 App details improve mobile and icon (#52275)
* icon instead of logo, enable wrap

* Keep logo

* revert test url
2026-05-28 13:23:55 +02:00
Wendelin b73732acdb Card visibility-status use ha-alert (#52271) 2026-05-28 10:57:41 +01:00
Wendelin d950514104 Fix automation note keyboard a11y (#52270) 2026-05-28 10:56:12 +01:00
Simon Lamon f37cf1e848 Don't redispatch the original event in a checklist item (#52242) 2026-05-28 08:26:00 +03:00
Paulus Schoutsen a188ef1b7a Fix resend-verification flash and concurrency on cloud signup (#52244)
Resending the confirmation email reused the registration code path, so
the flash on the login screen said "Account created!" even though no
new account was created. Pass a message key to _verificationEmailSent
so resend can show "Verification email sent." instead.

_handleResendVerifyEmail also never set _requestInProgress, so the
resend button (and the start-trial button, which share that flag) were
not disabled while a resend was in flight and could be clicked
repeatedly. Set the flag at the start and clear it on terminal errors;
_verificationEmailSent already clears it on success.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-28 08:11:54 +03:00
Wendelin 087ef159df Fix ha-radio-option checked theming (#52237)
Update ha-radio-option theming to use checked-icon-color for text and border
2026-05-27 15:58:00 +02:00
Bram Kragten e39e1b3f5b Merge branch 'rc' into dev 2026-05-27 15:29:28 +02:00
Bram Kragten ff583d2274 Bumped version to 20260527.0 2026-05-27 15:26:39 +02:00
Wendelin d4de29e073 Rename automation trigger behavior options (#52224) 2026-05-27 15:24:53 +02:00
Wendelin 97dfed0cc4 Rename automation comments to note (#52219) 2026-05-27 15:23:27 +02:00
Bram Kragten 8b3df752da Add associated zone option for device trackers (#52211) 2026-05-27 15:18:01 +02:00
Paul Bottein 8c0d547962 Render small media browser thumbnails without blur (#52230)
* Render small media browser thumbnails without blur

* Only 16 pixels and no svg

* Skip brand url

* Media selector
2026-05-27 15:17:20 +02:00
Wendelin 5e3d84f0ad Add live test state message tooltip (#52233) 2026-05-27 15:08:43 +02:00
Petar Petrov b4e30bdf63 Fix energy compare bars stacking when compare month has more days (#52221) 2026-05-27 15:01:33 +02:00
Petar Petrov 4fcae4231c Remove redundant log-axis non-positive data preprocessing (#52222) 2026-05-27 14:59:37 +02:00
Wendelin 2aecf33955 Fix app details in tablet width (#52234) 2026-05-27 14:54:02 +02:00
Paulus Schoutsen 5f26a2b3da Show verify-email flash after cloud signup (#52232)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-27 14:46:24 +02:00
Paul Bottein b08f5bcb34 Add custom card suggestions in the entity card picker (#52228)
* Add custom card suggestions in the entity card picker

* Prettier

* rename function

* Use ensure array
2026-05-27 14:38:53 +02:00
Wendelin c329e5b827 Revert "Automation triggers - auto IDs" (#52226) 2026-05-27 14:19:38 +02:00
Paulus Schoutsen 97f591337d Fix cloud TTS try dialog failing on default browser target (#52231)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:17:18 +02:00
Bram Kragten e6e6e75f73 Revert "Add-on iframe: delegate microphone + camera Permissions Policy" (#52229) 2026-05-27 13:08:57 +02:00
Wendelin ff334de0ca Fix checked radio option (#52227) 2026-05-27 12:46:14 +02:00
Bram Kragten 8dbe97b480 Add device step to matter add flow (#52216)
* Add device step to matter add flow

* Update matter-add-device-device-added.ts
2026-05-27 12:48:14 +03:00
Bram Kragten 7bea54851d Remove advanced mode completely (#52212) 2026-05-27 09:20:48 +00:00
renovate[bot] 7171575f8c Update dependency @html-eslint/eslint-plugin to v0.61.0 (#52220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 11:20:41 +03:00
renovate[bot] f4143c2070 Update dependency echarts to v6.1.0 (#52168)
* Update dependency echarts to v6.1.0

* Fix axis-proxy patch for echarts 6.1.0 AxisProxy internals

ECharts 6.1.0 uses hostedBy() and _window.value instead of direct model
comparison and _valueWindow. Update the boundaryFilter patch and contract
tests so CI passes with the dependency bump.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 11:08:23 +03:00
Paul Bottein bbe6b88533 Add more card suggestions in the entity card picker (#52218)
Co-authored-by: Wendelin <w@pe8.at>
2026-05-27 09:32:54 +02:00
Jan-Philipp Benecke 3a0c85cd3e Migrate top app bar to plain HTML and drop mwc dependency (#52165) 2026-05-27 08:57:38 +02:00
Petar Petrov d22e2b8dd5 Add battery state of charge badges to energy panel (#52210)
Show battery SOC as entity badge on energy Now tab

Co-authored-by: MindFreeze <noreply@anthropic.com>
2026-05-26 20:15:22 +02:00
Wendelin 45e7d86bf8 Increase helper font-size (#52214) 2026-05-26 13:41:37 +00:00
Petar Petrov d1bf5fe33c Use context instead of hass for localize in low level components (#52177) 2026-05-26 15:26:09 +02:00
Aidan Timson fb0a54231a Show device name tip with link to editor, disable update button when state is clean (#52024) 2026-05-26 13:32:16 +02:00
Jan-Philipp Benecke a147fc4fee Fix padding of vertical tile card content (#52198) 2026-05-26 08:21:05 +03:00
karwosts a300085208 Fix calendar panel for non-admin (#52203) 2026-05-26 08:20:26 +03:00
renovate[bot] 44989a6972 Update dependency date-fns to v4.3.0 (#52205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 08:19:30 +03:00
Yosi Levy 54a8e6c294 RTL fix for automation row (#52200) 2026-05-25 12:38:34 +02:00
Yosi Levy bfec22d828 RTL fix for new suggestion tree (#52199) 2026-05-25 11:52:54 +02:00
steven cde6450cfc Fix stale wake word display after wake word change in voice satellite set up wizard (#52194)
Fix stale wake word display after wake word change in satellite wizard

The config re-fetch was fire-and-forgotten, so the step transition to
STEP.WAKEWORD raced ahead with stale assistConfiguration. Awaiting the
fetch ensures the fresh active_wake_words are in place before rendering.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:41:56 +00:00
Petar Petrov ab39e70629 Recover brand icons after Home Assistant restart (#52158)
* Recover brand icons after Home Assistant restart

* Make _refreshBrandsAccessToken async
2026-05-25 09:36:50 +02:00
J. Nick Koston 69f209e3c3 Teach Bluetooth UI about auto scanning mode (#52192)
* Teach Bluetooth UI about auto scanning mode

* Drop unreachable auto cases and add isScannerStateMismatch tests
2026-05-25 09:30:03 +02:00
J. Nick Koston f4c5561a54 Show raw advertisement bytes in Bluetooth device info (#52193)
* Show raw advertisement bytes in Bluetooth device info

* Use plain div for raw hex to avoid fragile pre whitespace
2026-05-25 09:24:46 +02:00
renovate[bot] 5147937a6f Update dependency generate-license-file to v4.2.1 (#52195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:42 +02:00
renovate[bot] ee39605aa7 Update dependency intl-messageformat to v11.2.7 (#52197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:24:38 +02:00
renovate[bot] 4af4f1dc51 Update dependency idb-keyval to v6.2.4 (#52190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:01:55 +00:00
renovate[bot] a2d8859d94 Update dependency @date-fns/tz to v1.5.0 (#52187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 14:03:30 +03:00
renovate[bot] afea8180c4 Update dependency idb-keyval to v6.2.3 (#52186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 13:53:50 +03:00
dependabot[bot] b9c077489d Bump github/codeql-action from 4.35.4 to 4.35.5 (#52183)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.5
  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-05-24 09:33:38 +03:00
karwosts 440bb32056 Remove unintended sort from select selector (#52179) 2026-05-23 21:14:24 +02:00
renovate[bot] 8f371621ad Update dependency @rspack/core to v2.0.4 (#52178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 16:18:20 +03:00
renovate[bot] 61815b20e3 Update vitest monorepo to v4.1.7 (#52173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 13:03:54 +02:00
ildar170975 1942fa3a77 hui-entity-editor: fix vertical spacings (#52170)
fix spacings
2026-05-23 10:04:46 +02:00
renovate[bot] 865e67a06f Update Yarn to v4.15.0 (#52169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:03:23 +02:00
renovate[bot] 412dce4c1f Update dependency tinykeys to v4 (#52172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 10:02:58 +02:00
Jan-Philipp Benecke ced2ac7ad5 Fix ha-drawer z-index (#52167)
Fix ha-drawer index
2026-05-22 20:33:05 +02:00
renovate[bot] 6649f52bcd Update dependency tinykeys to v3.1.0 (#52166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 17:38:26 +00:00
Pascal Vizeli 7dbd6ae5a2 Add-on iframe: delegate microphone + camera Permissions Policy (#52068)
* Add-on iframe: delegate microphone + camera Permissions Policy

The add-on ingress iframe in ``ha-panel-app.ts`` ships without an
``allow=`` attribute, so the Permissions Policy default of *deny*
applies for ``microphone`` and ``camera`` on the cross-origin
iframe. An add-on that wants to call ``getUserMedia`` — voice
notes, dictation, video calls, photo capture — fails silently with
``NotAllowedError`` before the browser even surfaces the permission
prompt.

The failure is most visible on the Android Companion app, where
there's no "open in a new tab" escape: the user presses the mic
button and nothing happens, no toast, no logs.

Delegate ``microphone``, ``camera``, and ``clipboard-write`` to the
add-on iframe. Add-ons are first-party software the user explicitly
installs, and Chrome's runtime permission prompt still gates the
hardware access — the ``allow=`` attribute just lets the iframe
*request* the prompt instead of being blocked at the policy layer.

``clipboard-write`` is bundled in because the next-most-frequent
silent-fail in add-on land is ``navigator.clipboard.writeText`` for
"copy link" / "copy code" affordances, blocked by the same
mechanism.

* Sandbox add-on ingress iframe without allow-same-origin

Split IFRAME_SANDBOX into two constants: IFRAME_SANDBOX (without
allow-same-origin) for add-on ingress iframes that need origin
isolation, and IFRAME_SANDBOX_SAME_ORIGIN for external iframes
that need same-origin access.

This ensures add-on iframes can't inherit camera/microphone
permissions already granted to the Home Assistant origin, and
prevents same-origin iframes from removing their own sandbox.

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

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 19:31:56 +02:00
Wendelin e1528d21b3 Migrate md-lists cloud dashboard and devtools (#52163)
Migrate lists in cloud and dev tools=
2026-05-22 18:24:16 +03:00
renovate[bot] 79cb3137f2 Update tsparticles to v4.0.5 (#52162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 18:17:04 +03:00
Jan-Philipp Benecke 313360701a Revamp ZHA group page UI (#52124) 2026-05-22 16:24:59 +02:00
renovate[bot] b100d9577d Update formatjs monorepo (#52159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 15:56:02 +03:00
Wendelin 44ce303302 Fix dropdown keyboard scroll (#52157) 2026-05-22 13:47:43 +01:00
Aidan Timson 8f76613068 Add more quick links to device page (#52137)
* Add more quick links to device page

* Move shared keys to common location
2026-05-22 12:45:37 +03:00
Aidan Timson 85dff6640a Use ha-list-nav for each section in device page related card (#52142)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-22 11:22:36 +02:00
Aidan Timson ab7c892b6b Add to for area page (#52141)
* Setup add to area page

* Remove 3 buttons, move to single add to button next to add a picture button

* Use normal size buttons

* Restructure layout with picture

* Remove div when both conditions are met

* Use mixin

* Fix imports
2026-05-22 12:02:38 +03:00
Wendelin 3fe57ad724 webawesome 3.7.0 (#52155) 2026-05-22 11:24:16 +03:00
pcan08 1caf1d99b5 Migrate theme-picker to ha-generic-picker (#52067)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-05-22 06:32:39 +00:00
renovate[bot] 483df2fa2f Update dependency marked to v18.0.4 (#52153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:52:04 +03:00
Stefan Agner e0adb006b6 Show time zone picker in onboarding when browser can't resolve IANA zone (#52146)
Some environments (e.g. Android WebView/emulator) return a UTC offset like
"+00:00" from Intl.DateTimeFormat().resolvedOptions().timeZone instead of an
IANA zone name. Submitting that to saveCoreConfig fails with "invalid time
zone", leaving users stuck on the country step.

Detect this by checking the resolved value against the google-timezones-json
list used by ha-timezone-picker, and surface the picker on the core-config
step when no IANA zone could be detected from the browser or the location
detect API.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:26:50 +03:00
renovate[bot] 50e34015b3 Update tsparticles to v4.0.4 (#52152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:23:24 +03:00
renovate[bot] c1c926c631 Update tsparticles to v4.0.3 (#52148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:11:18 +03:00
renovate[bot] c41afac57c Update dependency typescript-eslint to v8.59.4 (#52147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 08:10:58 +03:00
Aidan Timson 8856c26929 Add quick links to area page, add area query param support (#52133)
* Add quick links to area page

* Typing

* Add query param support for areas

* hint for the rest

* Add support for helpers

* Add counts
2026-05-21 18:59:20 +02:00
Wendelin 4a0fe3190c Backups: Migrate md-list to new list-base (#52136)
Migrate md-list to new list-base
2026-05-21 18:57:24 +02:00
karwosts 08f7e97462 Use display precision in statistic card (#52138) 2026-05-21 18:53:04 +02:00
Joakim Plate a5791c8c08 Switch to power-standby for media player (#52127) 2026-05-21 18:53:01 +02:00
Wendelin 6a98a74c58 Fix trigger time margin bottom (#52144)
Fix trigger time margin
2026-05-21 18:39:35 +02:00
renovate[bot] c1df3bc38e Update Node.js to v24.16.0 (#52140) 2026-05-21 17:03:13 +02:00
Wendelin 58d4edaa63 Respect via device in device picker, device list (#52131)
* Respect via device in device picker

* Add context to device data table too
2026-05-21 16:01:43 +02:00
Wendelin 176841e647 Automation triggers - auto IDs (#52129)
* Add auto trigger ids

* improve

* Fix paste with no trigger id

* Fix trigger-id-chip and add jsdocs

* review
2026-05-21 16:52:03 +03:00
renovate[bot] 0759e82b47 Update dependency date-fns to v4.2.1 (#52135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 16:44:50 +03:00
karwosts 7c6609aee7 Make external statistic card unclickable (#52139) 2026-05-21 16:07:43 +03:00
Paul Bottein 7048c5f3d2 Add entity-first card picker for dashboard (#51651)
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-21 15:01:10 +02:00
Aidan Timson 9ed47be6c3 Add to for devices page, merge 3 cards into 1 related card (#52119)
* Add to for devices page

* Rename and reuse original dialogs, drop popover

* Reduce

* Lazy context

* Direct access lazy context

* Default width

* Merge automations and scripts cards

* Format

* Loading state

* Rename key

* Tooltip and move key

* Copy icons used in more info

* Sort

* Merge scenes into one "Related" card

* Adjust

* Fix no labs

* Use same wording for device actions

* Cleanup

* Comments for removal

* Cleanup

* Type check

* Template literals

* Add padding
2026-05-21 15:35:14 +03:00
Aidan Timson 128f4526e3 Fix no entities message in area page, improve message (#52130) 2026-05-21 15:23:26 +03:00
Wendelin 3f1b7ce391 Add automation item comments (#52090)
* Add automation comments

* Line wrap

* Review

* Add truncateWithEllipsis with tests

* Review
2026-05-21 13:50:51 +03:00
renovate[bot] 4073b4e1f5 Update dependency date-fns to v4.2.0 (#52132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 13:46:57 +03:00
Paul Bottein 86a24d1532 Add temperature and precipitation forecast card features (#51866)
* Rework weather forecast card features

* Add show labels option

* Some fixes

* Fixes and cleaning

* Update palette

* Add reference floor to precipitation bar scale

Light drizzle no longer fills the bar when it's also the period max.
Observed values above the floor still drive the scale (storms read full).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Feedbacks

* Use weather unit

* Force celcius for gradient

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:42:26 +03:00
Wendelin 46bab5bb01 Automation row target count (#52118)
* Add automation row target count

* fix types
2026-05-20 16:23:40 +03:00
iluvdata e8f486af0a Allow integrations to specify the "domain" of the entity that is rendered in previews (#51829)
* Allow previews to use a domain

* Allow previews to specify preview entity domain

* Allow repair_flow to use previews

* Pull recent changes

* Add domain to previews for TemplatePreview
2026-05-20 12:30:03 +03:00
AlCalzone 211579eade Add Z-Wave credential mangement (#51591)
* first rough draft of Z-Wave credential mangement

* separate user and credentials, error handling, dialog tweaks

* align with upstream API changes, improve error handling

* align more with Matter, use lock entity for services

* remove get_credential_status service

* address review feedback, clarify user types

* user_index -> user_id, fix some pending states

* address review feedback

* clean up unused code, strongly type credential types

* Clear -> Delete, drop icons

* Simplify flow to 1 PIN/Password credential per user

* cleanup, comments, etc.

* address review feedback

* do not show existing credential data

* fix lint errors after branch update

* ignore non-enterable credential types when editing user
2026-05-20 12:05:13 +03:00
markvp f6458925c9 Add hidden device firmware column (#52117) 2026-05-20 08:35:32 +00:00
karwosts ae5e35e7ed Include low battery binary_sensors in low battery count (#52115)
Include low binary battery sensors in low battery count
2026-05-20 08:35:11 +03:00
karwosts 8c1727859a Restore battery chips in Home areas strategy (#52114) 2026-05-20 08:27:12 +03:00
Wendelin 287562221f Remove live condition test tooltip (#52103)
* Remove live condition test hover

* Update src/components/automation/ha-automation-row-live-test.ts

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

* Review

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-20 08:21:45 +03:00
Aidan Timson 2593dfed8d Type assertion and signature improvements for hui changed handlers (#52109)
* Type assertion and signature improvements

* Improve
2026-05-19 20:06:20 +02:00
pcan08 2d92f1fb3b Forget filter from url: remaining pages (#52061)
* refactor: use separate storage and display filters in backup page

Apply the two-lists pattern in backup page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false). _storageFilters
is only updated when not in URL mode (_fromUrl flag). Init moved from
connectedCallback to willUpdate(!hasUpdated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters in scenes page

Apply the two-lists pattern in scenes page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false, with
serializer/deserializer). _storageFilters is only updated when not in
URL mode (_fromUrl flag). Init moved from firstUpdated to
willUpdate(!hasUpdated). The existing updated() hook already calls
_applyFilters() when _entityReg changes, covering the reconnect case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters in automations page

Apply the two-lists pattern in automations page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false, with
serializer/deserializer). _storageFilters is only updated when not in
URL mode (_fromUrl flag). _fromUrl is set before the await in the async
_filterBlueprint() to prevent any user change during the fetch from
persisting. Init moved from firstUpdated to willUpdate(!hasUpdated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters in scripts page

Apply the two-lists pattern in scripts page: _filters (@state, display only) +
_storageFilters (@storage sessionStorage, state: false, with
serializer/deserializer). _storageFilters is only updated when not in
URL mode (_fromUrl flag). _fromUrl is set before the await in the async
_filterBlueprint() to prevent any user change during the fetch from
persisting. Init moved from firstUpdated to willUpdate(!hasUpdated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: don't mix URL filters with storage filters in automation,script and scene pages

When URL params are present, _filters starts empty so URL methods build
from scratch. Previously, _filters was pre-populated from _storageFilters
and the spread in _filterLabel()/_filterBlueprint() would merge storage
filters into the URL-injected ones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/panels/config/backup/ha-config-backup-backups.ts

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-05-19 17:47:47 +00:00
pcan08 8cff4c6bd2 Helpers page: forget filter from url (#51989)
* fix(helpers): clear URL-injected filters on leaving helpers dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(helpers): restore previous filters after URL-injected navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use separate storage and display filters

Apply the same pattern as devices and entities pages: split _filters into
a display-only @state and a _storageFilters persisted to sessionStorage.
_storageFilters is only updated when not in URL mode (_fromUrl flag), so
URL-injected filters never persist to storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: reapply filters when helper entities load on reconnect

_applyFilters() was never called when _filters was restored from
sessionStorage, leaving _filteredHelperEntityIds undefined and the
table appearing empty. Call it whenever _helperEntities updates and
active filters are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:43:31 +02:00
renovate[bot] 5aa8455861 Update tsparticles to v4.0.2 (#52110) 2026-05-19 14:08:32 +00:00
renovate[bot] 4d142734d8 Migrate Renovate config (#52105) 2026-05-19 14:07:06 +00:00
Aidan Timson eaecc76f36 Add to automation/script with triggers/conditions/actions (#51871)
* Setup default add to actions

* Setup default add to actions

* Move event into external only

* Split into sections

* Padding

* Refactor to single type and adapt app interface to frontend style and vice versa

* Refactor to single type and adapt app interface to frontend style and vice versa

* Condition action and navigation actions

* Open dialogs with trigger, condition, action dialogs

* Add divider before add to

* Move add to to the top

* Action

* Triggers and conditions labs feature check

* Suggestion

* Keep query state

* Change to automation_trigger

* Use typed key instead of finding with icon

* Apply suggestions from code review

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Finish

* Reset state

* Fix navigation resets

* stated

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Split

* Add import, sort imports

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-19 14:57:44 +02:00
Aidan Timson 7dc0033c03 Improve typing on value-changed handlers in card features and state controls (#52107) 2026-05-19 14:28:52 +02:00
Paul Bottein 601e6d0542 Distinguish unknown from unavailable entity state (#52089) 2026-05-19 15:24:50 +03:00
Aidan Timson c7ca3dd837 Typing assertion and generic improvements on area controls and conditions (#52106) 2026-05-19 15:11:31 +03:00
renovate[bot] f75a376add Update dependency lint-staged to v17.0.5 (#52104) 2026-05-19 12:54:34 +01:00
Aidan Timson a541204ffb Match python version with core version (#52102) 2026-05-19 13:27:31 +02:00
Aidan Timson cbbce90eae Remove YARN_VERSION from netlify.toml (inherit packageManager) (#52101) 2026-05-19 13:26:33 +02:00
Wendelin 950de204aa Restyle and improve app info (#52100) 2026-05-19 09:37:38 +01:00
Bram Kragten 9298e00f20 Merge branch 'rc' 2026-05-14 11:32:44 +02:00
Bram Kragten 70085d4bad Bumped version to 20260429.4 2026-05-14 11:32:29 +02:00
Wendelin d83a553b62 Reactivate iOS focus element (#52020) 2026-05-14 11:31:23 +02:00
Wendelin cab5c6af30 Add macOS version mapping for Safari 26 support (#51999) 2026-05-14 11:24:44 +02:00
Petar Petrov d44d8a6dbd Fix water sankey untracked consumption with nested sub-trackers (#51998) 2026-05-14 11:24:43 +02:00
karwosts 3cf1d94b92 Fix sensor card when visibility changes (#51953)
* Fix sensor card when visibility changes

* History card

* map card

* trend graph

* minor change
2026-05-14 11:23:31 +02:00
Tom Carpenter 9f5f849e32 Fix demo instance mock recorder data generation (#51950)
Fix demo mock recorder data end times

The mock recorder was setting the start and end time for each of the samples to be the same value, causing the solar graph in the energy dashboard to render incorrectly.

Fix the recorder to set the end time of each sample to the start time of the next.
2026-05-14 11:21:23 +02:00
karwosts 27e9926363 Fix heading badge current-entity visibility (#51942) 2026-05-14 11:21:22 +02:00
karwosts efe734892a Fix create new person with login (#51939) 2026-05-14 11:21:21 +02:00
Tom Carpenter b3d79e312d Remove extra padding to right of ha-switch (#51932)
Fix empty padding to right of ha-switch

When the label slot for the ha-switch is empty, the initial margin is still present which causes an odd misalignment on the switches in e,g, the entities card.

To fix this, if the label slot is empty, hide the label to remove the unwanted margin.
2026-05-14 11:21:19 +02:00
Marcin Bauer ecfef9e112 Improve continue on error tooltip in automation editor (#51926)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 11:21:18 +02:00
George Caliment ca960446f0 Fixed blueprint rows event result chip render when collapsed (#51910) 2026-05-14 11:21:17 +02:00
Petar Petrov a6eb722025 Clamp power sources graph usage line to non-negative (#51902) 2026-05-14 11:21:16 +02:00
Paul Bottein f3ff01ace4 Fix race condition loading home dashboard favorites (#51901) 2026-05-14 11:21:15 +02:00
karwosts d5e1a373ec Fix entity filter card (#51895) 2026-05-14 11:21:14 +02:00
Wendelin e1b9a1a185 Fix content padding picker (#51889) 2026-05-14 11:21:12 +02:00
Paul Bottein efe8eaa941 Move logs page search bar out of the toolbar (#51887) 2026-05-14 11:21:11 +02:00
Wendelin 5856196ef3 Improve automation event chips action, condition (#51886) 2026-05-14 11:21:10 +02:00
Clément Notin 2671a8c64b Fix quick bar search not focused on first open (#51822)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-14 11:21:09 +02:00
Bram Kragten 8620653a54 Merge branch 'rc' 2026-05-06 11:19:42 +02:00
Bram Kragten c4f4cbd323 Bumped version to 20260429.3 2026-05-06 11:18:01 +02:00
Paul Bottein 2e0df00f0f Fix name for battery entities without device (#51879) 2026-05-06 11:17:09 +02:00
Wendelin ce02f8072d Reduce progress bar default height (#51878)
reduce progress bar default height to 12px
2026-05-06 11:17:08 +02:00
Paul Bottein c973aa7516 Fix media controls in media player more info dialog (#51877) 2026-05-06 11:17:07 +02:00
Paul Bottein 1e2328707c Fix switch clipping in view visibility editor (#51876) 2026-05-06 11:17:06 +02:00
Wendelin 56368b88cd Remove duplicate definition in semantic colors (#51875)
* Remove duplicate definition in semantic colors

* rearrange surface tokens
2026-05-06 11:17:05 +02:00
Aidan Timson fcd4f177c1 Fix Safari 14 legacy bundle require errors (#51868)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 11:17:04 +02:00
Wendelin 7423ae7316 Fix integration search shrink on mobile (#51867) 2026-05-06 11:17:03 +02:00
Marcin Bauer 4427c581f1 Fix automation row right padding and soften chip highlight animation (#51865)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 11:17:02 +02:00
Paul Bottein cf86bb9821 Use ha-switch instead of ha-control-switch in entity toggle (#51852) 2026-05-06 11:17:01 +02:00
karwosts 897802dc16 Change display for uptime sensors (#51830) 2026-05-06 11:17:00 +02:00
Paul Bottein 95edd6c2c2 20260429.2 (#51856) 2026-05-04 17:05:24 +02:00
Paul Bottein dd65173c5a Bumped version to 20260429.2 2026-05-04 17:04:06 +02:00
Paul Bottein cf26753f7d Remove daily and hourly forecast card features (#51854) 2026-05-04 17:03:54 +02:00
Paul Bottein d6ab8ffb16 Resolve service name and icon for shortcut card and badge (#51850) 2026-05-04 17:03:53 +02:00
Wendelin 2dc4b16eac Fix automation row target width (#51848) 2026-05-04 17:03:52 +02:00
Paul Bottein 1eba765bc2 Group areas floor vacuum clean (#51847) 2026-05-04 17:03:51 +02:00
Wendelin 398479ddd7 Use ha-switch in ha-automation-picker (#51846)
use ha-switch in ha-automation-picker
2026-05-04 17:03:50 +02:00
Paul Bottein c4fd7bb3e1 Fix entity toggle switch size (#51845) 2026-05-04 17:03:49 +02:00
Isaac (Kwangjin Ko) 4cfc67a95e ha-humidifier-state: fix incorrect translation key for 'Currently' (#51843) 2026-05-04 17:03:48 +02:00
Brooke Hatton e38d1964ca Remove battery chargers from maintenance dashboard (#51835) 2026-05-04 17:03:47 +02:00
Paul Bottein ec8b5c77bd Add min touch size for control switch (#51826) 2026-05-04 17:03:46 +02:00
Simon Lamon 425f2775e2 Missing toggle in switch group (#51825)
Missing toggle
2026-05-04 17:03:45 +02:00
Brooke Hatton 3a3d8191a3 Adjust Copy for maintenance summary card and include unavailable device count (#51815)
* Adjust Copy For summary card

* Further tweak copy and include unavailable devices
2026-05-04 17:03:44 +02:00
Aidan Timson 04fca68549 Add gap between hui editors and previews on mobile (#51811) 2026-05-04 17:03:43 +02:00
Paul Bottein 3046f3e47d 20260429.1 (#51817) 2026-04-30 20:33:33 +02:00
Paul Bottein 35601a0900 Bumped version to 20260429.1 2026-04-30 20:32:28 +02:00
Wendelin e7016c15af Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:32:08 +02:00
Wendelin 624521e30b Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 20:32:08 +02:00
Bram Kragten 4876bfa639 Add tooltips for Jinja editors (#51792)
* Add descriptions to Jinja2 tags, filters, expressions, tests and variables

All standard Jinja2 tags, filters, and expression completions now carry
info and detail strings so the autocomplete info popover shows meaningful
documentation when users browse them — not just HA-specific functions.

* Add keyboard shortcut tip to the template developer tool

A ha-tip below the editor card now shows users that Ctrl+Space triggers
autocomplete, Ctrl+F opens the search panel, and F11 toggles fullscreen,
making the editor's built-in features more discoverable.

* Add hover tooltips for Jinja2 functions, filters and expressions

Hovering over a function, filter, tag, test, or variable name inside a
Jinja2 template shows a tooltip with its signature and description.
Non-tag completions also get a help-circle icon linking to the
corresponding Home Assistant template-functions documentation page.

The tooltip is rendered as a custom Lit element (ha-code-editor-jinja-hover)
that takes the Completion object and an optional docUrl as properties.

The tooltip source (haJinjaHoverSource) is wired into ha-code-editor
via CodeMirror's hoverTooltip extension. The documentationUrl() helper
is used so the link points to the correct subdomain (www / rc / next)
based on the running HA version.

* Add hover tooltips for Jinja2 hover + arg value tooltips for entity/device/area

Wire haJinjaHoverSource into ha-code-editor via CodeMirror hoverTooltip.
Two types of hover are now shown in jinja2/yaml mode:

- Hovering a function/filter/tag/expression name shows its signature,
  description, and a doc-link icon (non-tags only).
- Hovering a string-literal argument of a known HA Jinja function (e.g.
  states(), device_name(), area_entities()) shows the friendly name,
  current state, device, and area for entity_id arguments; the device
  name and area for device_id arguments; and the area name for area_id
  arguments. The same applies to states["entity_id"] subscripts.

The arg-value tooltip reuses CompletionItem / ha-code-editor-completion-items
(the same component used for autocomplete info popovers) via a new
ha-code-editor-jinja-arg-hover element. HA registry data is passed from
ha-code-editor via a HassArgHoverContext interface to keep jinja_ha_completions.ts
free of HomeAssistant type imports.

* only add tip for autocomplete

* review
2026-04-30 20:32:07 +02:00
AlCalzone 5dea0764b2 Expose Z-Wave exclusion instructions when removing device (#51788)
* Expose Z-Wave exclusion instructions when removing device

* text tweaks

* Apply suggestion from @MindFreeze

* Apply suggestions from code review

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

* bring back comment

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 20:32:05 +02:00
Paul Bottein 121ed7ac1f 20260429.0 (#51790) 2026-04-29 16:47:32 +02:00
837 changed files with 32398 additions and 14600 deletions
+91 -81
View File
@@ -2,12 +2,13 @@
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
- [Quick Reference](#quick-reference)
- [Core Architecture](#core-architecture)
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
- [Development Standards](#development-standards)
- [Component Library](#component-library)
- [Common Patterns](#common-patterns)
@@ -40,7 +41,7 @@ script/develop # Development server
```typescript
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
```
## Core Architecture
@@ -52,13 +53,64 @@ The Home Assistant frontend is a modern web application that:
- Communicates with the backend via WebSocket API
- Provides comprehensive theming and internationalization
## State Access: Contexts Instead of `hass`
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
### Contexts
Consume the narrowest context that covers your reads:
| Context | Replaces |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
### Consuming
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
private _entity?: EntityRegistryDisplayEntry;
@state() @consumeLocalize()
private _localize!: LocalizeFunc;
```
For any other single field, pair `@consume` with `@transform`:
```ts
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
private _themes!: Themes;
```
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
## Development Standards
### Code Quality Requirements
**Linting and Formatting (Enforced by Tools)**
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
- Prettier with ES5 trailing commas enforced
- No console statements (`no-console: "error"`) - use proper logging
- Import organization: No unused imports, consistent type imports
@@ -136,6 +188,7 @@ export class HaMyComponent extends LitElement {
### Data Management
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
- **Cache appropriately**: Use collections and caching for frequently accessed data
- **Handle errors gracefully**: All API calls should have error handling
- **Update real-time**: Subscribe to state changes for live updates
@@ -160,7 +213,7 @@ try {
- Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Follow Material Design**: Use Material Web Components where appropriate
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
- **Support RTL**: Ensure all layouts work in RTL languages
```typescript
@@ -267,22 +320,24 @@ fireEvent(this, "show-dialog", {
**Dialog Sizing:**
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
**Button Appearance Guidelines:**
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
Common patterns:
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
- **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[]`
@@ -301,14 +356,11 @@ fireEvent(this, "show-dialog", {
></ha-form>
```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
- Slots: `icon` (override the leading icon), `action` (custom action content)
- Content announced by screen readers when dynamically displayed
```html
@@ -317,10 +369,6 @@ fireEvent(this, "show-dialog", {
<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.
@@ -344,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
@@ -374,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")
@@ -447,9 +494,13 @@ 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 use `querySelector` - Use refs or component properties
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
- Don't manipulate DOM directly - Let Lit handle rendering
- Don't use global styles - Scope styles to components
- Don't block the main thread - Use web workers for heavy computation
@@ -477,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
@@ -540,35 +591,24 @@ When creating a pull request, you **must** use the PR template located at `.gith
#### Translation Considerations
- **Add translation keys**: All user-facing text must be translatable
- **Use placeholders**: Support dynamic content in translations
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
- **Keep context**: Provide enough context for translators
```typescript
// Good
this.hass.localize("ui.panel.config.automation.delete_confirm", {
name: automation.alias,
});
// Bad - hardcoded text
("Are you sure you want to delete this automation?");
```
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
### Common Review Issues (From PR Analysis)
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
#### User Experience and Accessibility
- **Form validation**: Always provide proper field labels and validation feedback
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
- **Loading states**: Show clear progress indicators during async operations
- **Error handling**: Display meaningful error messages when operations fail
- **Mobile responsiveness**: Ensure components work well on small screens
- **Hit targets**: Make clickable areas large enough for touch interaction
- **Visual feedback**: Provide clear indication of interactive states
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
#### Dialog and Modal Patterns
- **Dialog width constraints**: Respect minimum and maximum width requirements
- **Interview progress**: Show clear progress for multi-step operations
- **State persistence**: Handle dialog state properly during background operations
- **Cancel behavior**: Ensure cancel/close buttons work consistently
@@ -580,15 +620,12 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
- **Grid alignment**: Components should align to the design grid system
- **Badge placement**: Position badges and indicators consistently
- **Color theming**: Respect theme variables and design system colors
#### Code Quality Issues
- **Null checking**: Always check if entities exist before accessing properties
- **TypeScript safety**: Handle potentially undefined array/object access
- **Import organization**: Remove unused imports and use proper type imports
- **Event handling**: Properly subscribe and unsubscribe from events
- **Memory leaks**: Clean up subscriptions and event listeners
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
#### Configuration and Props
@@ -599,39 +636,12 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
## Review Guidelines
### Core Requirements Checklist
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
- [ ] TypeScript strict mode passes (`yarn lint:types`)
- [ ] No ESLint errors or warnings (`yarn lint:eslint`)
- [ ] Prettier formatting applied (`yarn lint:prettier`)
- [ ] Lit analyzer passes (`yarn lint:lit`)
- [ ] Component follows Lit best practices
- [ ] Proper error handling implemented
- [ ] Loading states handled
- [ ] Mobile responsive
- [ ] Theme variables used
- [ ] Translations added
- [ ] Accessible to screen readers
- [ ] Tests added (where applicable)
- [ ] No console statements (use proper logging)
- [ ] Unused imports removed
- [ ] Proper naming conventions
### Text and Copy Checklist
- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add)
- [ ] Localization keys added for all user-facing text
- [ ] Uses "Home Assistant" (never "HA" or "HASS")
- [ ] Sentence case for ALL text (titles, headings, buttons, labels)
- [ ] American English spelling
- [ ] Friendly, informational tone
- [ ] Avoids abbreviations and jargon
- [ ] Correct terminology (integration not component)
### Component-Specific Checks
- [ ] ha-alert used correctly for messages
- [ ] ha-form uses proper schema structure
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
- [ ] Tests added for new data processing/utilities (where applicable)
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
- [ ] Components handle all states (loading, error, unavailable)
- [ ] Entity existence checked before property access
- [ ] Event subscriptions properly cleaned up
- [ ] Event/subscription listeners cleaned up (no memory leaks)
- [ ] Accessible to screen readers and keyboard
+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();
}
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev
npx -y netlify-cli deploy --dir=cast/dist --alias dev
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
@@ -82,7 +82,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod
npx -y netlify-cli deploy --dir=cast/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+2 -2
View File
@@ -47,7 +47,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
@@ -83,7 +83,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod
npx -y netlify-cli deploy --dir=gallery/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Deploy preview to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
--json > deploy_output.json
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -5
View File
@@ -13,13 +13,11 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with:
github-token: ${{ github.token }}
process-only: "issues, prs"
issue-lock-inactive-days: "30"
issue-exclude-created-before: "2020-10-01T00:00:00Z"
issue-inactive-days: "30"
issue-lock-reason: ""
pr-lock-inactive-days: "1"
pr-exclude-created-before: "2020-11-01T00:00:00Z"
pr-inactive-days: "1"
pr-lock-reason: ""
+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 }}
+16 -3
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -77,9 +77,22 @@ jobs:
env:
GITHUB_REF: ${{ github.ref }}
run: |
# Sleep to give pypi time to populate the new version across mirrors
sleep 240
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
# Wait for the package to become available on PyPI
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
for i in $(seq 1 30); do
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
if [ "$status" = "200" ]; then
echo "Package is available on PyPI!"
break
fi
if [ "$i" = "30" ]; then
echo "Timed out waiting for package to appear on PyPI"
exit 1
fi
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
sleep 30
done
echo "home-assistant-frontend==$version" > ./requirements.txt
# home-assistant/wheels doesn't support SHA pinning
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
+1 -1
View File
@@ -1 +1 @@
24.15.0
24.16.0
@@ -0,0 +1,86 @@
diff --git a/dist/tinykeys.cjs b/dist/tinykeys.cjs
index 08c98b6eff3b8fb4b727fe8e6b096951d6ef6347..9c44f14862f582766ea1733b6dc0e97f962800d8 100644
--- a/dist/tinykeys.cjs
+++ b/dist/tinykeys.cjs
@@ -61,6 +61,18 @@ function defaultKeybindingsHandlerIgnore(event) {
function getModifierState(event, mod) {
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
}
+function splitKeybindingPress(press) {
+ let parts = [];
+ let start = 0;
+ for (let index = 0; index < press.length; index++) {
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
+ parts.push(press.slice(start, index));
+ start = index + 1;
+ }
+ }
+ parts.push(press.slice(start));
+ return parts;
+}
/**
* Parses a keybinding string into its parts.
*
@@ -76,10 +88,10 @@ function getModifierState(event, mod) {
*/
function parseKeybinding(str) {
return str.trim().split(" ").map((press) => {
- let parts = press.split(/(?<=\w|\])\+/);
+ let parts = splitKeybindingPress(press);
let last = parts.pop();
let regex = last.match(/^\((.+)\)$/);
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
let requiredModifiers = [];
let optionalModifiers = [];
for (const part of parts) {
@@ -201,5 +213,3 @@ exports.defaultKeybindingsHandlerIgnore = defaultKeybindingsHandlerIgnore;
exports.matchKeybindingPress = matchKeybindingPress;
exports.parseKeybinding = parseKeybinding;
exports.tinykeys = tinykeys;
-
-//# sourceMappingURL=tinykeys.cjs.map
\ No newline at end of file
diff --git a/dist/tinykeys.mjs b/dist/tinykeys.mjs
index c289972d2728e03d9b272268c38fd3392e8845bf..e22897b00aae6cdb0dbbb971445227c07be52918 100644
--- a/dist/tinykeys.mjs
+++ b/dist/tinykeys.mjs
@@ -60,6 +60,18 @@ function defaultKeybindingsHandlerIgnore(event) {
function getModifierState(event, mod) {
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
}
+function splitKeybindingPress(press) {
+ let parts = [];
+ let start = 0;
+ for (let index = 0; index < press.length; index++) {
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
+ parts.push(press.slice(start, index));
+ start = index + 1;
+ }
+ }
+ parts.push(press.slice(start));
+ return parts;
+}
/**
* Parses a keybinding string into its parts.
*
@@ -75,10 +87,10 @@ function getModifierState(event, mod) {
*/
function parseKeybinding(str) {
return str.trim().split(" ").map((press) => {
- let parts = press.split(/(?<=\w|\])\+/);
+ let parts = splitKeybindingPress(press);
let last = parts.pop();
let regex = last.match(/^\((.+)\)$/);
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
let requiredModifiers = [];
let optionalModifiers = [];
for (const part of parts) {
@@ -196,5 +208,3 @@ function tinykeys(target, keybindingMap, options = {}) {
}
//#endregion
export { createKeybindingsHandler, defaultKeybindingsHandlerIgnore, matchKeybindingPress, parseKeybinding, tinykeys };
-
-//# sourceMappingURL=tinykeys.mjs.map
\ No newline at end of file
-940
View File
File diff suppressed because one or more lines are too long
+944
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.14.1.cjs
yarnPath: .yarn/releases/yarn-4.16.0.cjs
+3 -1
View File
@@ -57,7 +57,9 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent === "") {
hasDescription = false;
} else {
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
descriptionContent = marked(descriptionContent)
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`");
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
fs.writeFileSync(
path.resolve(galleryBuild, `${pageId}-description.ts`),
+1 -1
View File
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.6.0",
version: "5.7.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
+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 -16
View File
@@ -1,4 +1,3 @@
import "@material/mwc-top-app-bar-fixed";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
@@ -17,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);
}
`,
+538 -156
View File
@@ -1,161 +1,183 @@
import "@material/mwc-top-app-bar-fixed";
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">
<mwc-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>
</mwc-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 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>
<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>
${isSettingsPage || !page ? nothing : this._renderPageFooter(page)}
</ha-top-app-bar-fixed>
</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,48 +556,103 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
--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 - 64px);
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: 64px;
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 {
@@ -277,12 +662,21 @@ class HaGallery extends LitElement {
background: var(--primary-background-color);
}
ha-drawer[type="dismissible"][open] ha-top-app-bar-fixed {
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
}
.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 {
@@ -319,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);
}
`,
];
}
@@ -0,0 +1,48 @@
---
title: "Brand Personality"
---
# Brand Personality
These five traits describe who Home Assistant is, not how it speaks. Tone of voice the playfulness, the informality, the warmth, etc should flow naturally from these, and will help guide writers on how to bring the brand personality to life.
The first four traits are relational: they describe how Home Assistant behaves toward its users.
_Welcoming_ is about how we receive them.\
_Candid_ is about how we communicate with them.\
_Supportive_ is about how we help them.\
_Generous_ is about how/what we give to them.\
If any of these feel similar, its because theyre all expressions of the same underlying character, just in different moments of the user relationship.
_Independent_ is different. Its foundational: it describes who Home Assistant is at its core.\
And its because of that independence that the other four traits feel genuine rather than performed. A corporate brand can try to be welcoming, candid, supportive, and generous,
but without independence, those traits will always be managed and moderated.
## Welcoming
**Warm and open, kind, friendly, approachable, accommodating**\
_But not: people pleasing, appeasing, sycophantic, ingratiating_\
Home Assistant feels like a knowledgeable friend, not a product. We meet you at your own level, never talk down to you, and make you feel valued regardless of your technical ability. This isnt performative, its expressed naturally in the small things: how errors are explained, documentation is written, and how the community talks to newcomers.
## Candid
**Direct, honest, transparent, unpretentious**\
_But not: unfriendly, rude, blunt, unempathetic_\
Home Assistant says what it means. We dont hide complexity behind false simplicity, fall back on marketing fluff, or pretend limitations dont exist. We respect users enough to be straight with them: about what Home Assistant can do, what it can't, and why it works the way it does. This is what builds real trust with users.
## Supportive
**Helpful, guiding, encouraging, empathetic, genuine**\
_But not: directive, hollow, condescending, over-bearing_\
Home Assistant always has your back. Whether youre just starting out or deep into a complex setup, we steer you forward without taking over. Our support is genuine: practical, patient, and there when its needed most. Because Home Assistant wants every user to succeed in building a smart home with privacy, choice, and sustainability at its heart.
## Generous
**Empowering, trusting, giving, sharing**\
_But not: overwhelming, indiscriminate, patronizing, controlling_\
Home Assistant gives you everything you need today, and as your setup evolves. There are no strings attached: no artificial limits, features locked behind tiers, or deceptive patterns designed to tie you to a closed platform. We trust users with control, access, and transparency. This generosity is reciprocal: the time, knowledge, and care our community gives freely is what keeps Home Assistant thriving and truly open.
## Independent
**Principled, liberated, confident, imperfect**\
_But not: conceited, obstinate, erratic, insular_\
Home Assistant doesnt feel the need to behave like an established tech brand or follow corporate rules. With no shareholders or VCs to answer to, we can say what we think, do things our own way, and not take ourselves too seriously. This freedom of spirit comes from the confidence of knowing our own values, and that our community shares them.
+13 -37
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;
@@ -78,13 +78,13 @@ const alerts: {
title: "Error with action",
description: "This is a test error alert with action",
type: "error",
actionSlot: html`<ha-button size="small" slot="action">restart</ha-button>`,
actionSlot: html`<ha-button size="s" slot="action">restart</ha-button>`,
},
{
title: "Unsaved data",
description: "You have unsaved data",
type: "warning",
actionSlot: html`<ha-button size="small" slot="action">save</ha-button>`,
actionSlot: html`<ha-button size="s" slot="action">save</ha-button>`,
},
{
title: "Slotted icon",
@@ -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);
}
`;
@@ -26,7 +26,7 @@ title: Button
filled button
</ha-button>
<ha-button size="small">
<ha-button size="s">
small
</ha-button>
</div>
@@ -34,7 +34,7 @@ title: Button
```html
<ha-button> simple button </ha-button>
<ha-button size="small"> small </ha-button>
<ha-button size="s"> small </ha-button>
```
### API
@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
| disabled | Boolean | false | Disables the button and prevents user interaction. |
+14 -36
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`
@@ -49,7 +49,7 @@ export class DemoHaButton extends LitElement {
<ha-button
.appearance=${appearance}
.variant=${variant}
size="small"
size="s"
>
${titleCase(`${variant} ${appearance}`)}
</ha-button>
@@ -100,7 +100,7 @@ export class DemoHaButton extends LitElement {
<ha-button
.variant=${variant}
.appearance=${appearance}
size="small"
size="s"
disabled
>
${titleCase(`${appearance}`)}
@@ -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;
@@ -13,7 +13,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user **has made changes to**. Instead it will animate "no" by a little shake.
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
- Keep the labels short, for example `Save`, `Delete`, `Enable`.
+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;
}
`;
}
@@ -62,10 +62,11 @@ host reflects `aria-multiselectable`.
**Events**
- `ha-list-selected`selection changed. Detail
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
`index` is a `number` in single mode (`-1` when nothing selected) and a
`Set<number>` in multi mode.
- `ha-list-item-selected`an option was selected. Detail is the option's
index (`number`). In single mode this is the only selection event; in multi
mode it fires for each option added to the selection.
- `ha-list-item-deselected` — an option was deselected (multi mode only). Detail
is the option's index (`number`).
**Methods / getters**
+53 -13
View File
@@ -20,7 +20,6 @@ import "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-base";
import "../../../../src/components/list/ha-list-nav";
import "../../../../src/components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
type Appearance = "line" | "checkbox";
type Position = "start" | "end";
@@ -185,7 +184,7 @@ export class DemoHaList extends LitElement {
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-selected=${this._onSingle}
@ha-list-item-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
@@ -205,7 +204,8 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-selected=${this._onMultiLine}
@ha-list-item-selected=${this._onMultiLineSelected}
@ha-list-item-deselected=${this._onMultiLineDeselected}
>
${this._options.map(
(o, i) => html`
@@ -227,7 +227,8 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-selected=${this._onMultiCheckStart}
@ha-list-item-selected=${this._onMultiCheckStartSelected}
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
>
${this._options.map(
(o, i) => html`
@@ -253,7 +254,8 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-selected=${this._onMultiCheckEnd}
@ha-list-item-selected=${this._onMultiCheckEndSelected}
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
>
${this._options.map(
(o, i) => html`
@@ -347,20 +349,58 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
this._buttonClicks++;
};
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
private _withIndex(
value: number | Set<number>,
index: number,
selected: boolean
): Set<number> {
const next = new Set(value instanceof Set ? value : []);
if (selected) {
next.add(index);
} else {
next.delete(index);
}
return next;
}
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
};
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = ev.detail.index;
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
};
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
true
);
};
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
false
);
};
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
};
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(
this._multiCheckEnd,
ev.detail,
false
);
};
static styles = css`
@@ -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;
@@ -17,13 +17,13 @@ subtitle: A slider component for selecting a value from a range.
### Example Usage
<div class="wrapper">
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="medium"></ha-slider>
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="m"></ha-slider>
</div>
```html
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="medium"></ha-slider>
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
<ha-slider size="m"></ha-slider>
```
### API
+13 -38
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
@@ -29,7 +29,7 @@ export class DemoHaSlider extends LitElement {
></ha-slider>
<span>Small</span>
<ha-slider
size="small"
size="s"
min="0"
max="8"
value="4"
@@ -37,7 +37,7 @@ export class DemoHaSlider extends LitElement {
></ha-slider>
<span>Medium</span>
<ha-slider
size="medium"
size="m"
min="0"
max="8"
value="4"
@@ -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;
+53 -46
View File
@@ -1,10 +1,19 @@
import type { TemplateResult, PropertyValues } from "lit";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-tip";
import { provide } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import { provideHass } from "../../../../src/fake_data/provide_hass";
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",
@@ -12,59 +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 {
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 .hass=${provideHass(this)}>${tip}</ha-tip>`
)}
</div>
</ha-card>
</div>
`
)}`;
}
@provide({ context: internationalizationContext })
@state()
protected _i18n = DEMO_I18N;
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
);
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<ha-card slot=${slot}>
<div class="card-content">
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
</div>
</ha-card>
`
)}
</demo-theme-comparison>
`;
}
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
+31
View File
@@ -13,6 +13,28 @@ export interface NetworkInfo {
supervisor_internet: boolean;
}
interface SupervisorJob {
name: string;
reference: string | null;
uuid: string;
progress: number; // float, 0100
stage: string | null;
done: boolean;
errors: {
type: string;
message: string;
stage: string | null;
}[];
created: string; // ISO datetime string
extra: Record<string, unknown> | null;
child_jobs: SupervisorJob[];
}
export interface SupervisorJobInfo {
ignore_conditions: string[];
jobs: SupervisorJob[];
}
export const ALTERNATIVE_DNS_SERVERS: {
ipv4: string[];
ipv6: string[];
@@ -57,6 +79,15 @@ export async function getSupervisorNetworkInfo(): Promise<NetworkInfo> {
return responseData?.data;
}
export async function getSupervisorJobsInfo(): Promise<
HassioResponse<SupervisorJobInfo>
> {
const responseData = await handleFetchPromise<
HassioResponse<SupervisorJobInfo>
>(fetch("/supervisor-api/jobs/info"));
return responseData;
}
export const setSupervisorNetworkDns = async (
dnsServerIndex: number,
primaryInterface: string
+58 -6
View File
@@ -2,9 +2,9 @@ import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../src/common/url/search-params";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-alert";
import "../../src/components/ha-button";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
@@ -15,6 +15,7 @@ import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
import "./components/landing-page-network";
import {
getSupervisorJobsInfo,
getSupervisorNetworkInfo,
pingSupervisor,
type NetworkInfo,
@@ -24,6 +25,7 @@ import { LandingPageBaseElement } from "./landing-page-base-element";
export const ASSUME_CORE_START_SECONDS = 60;
const SCHEDULE_CORE_CHECK_SECONDS = 1;
const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
@customElement("ha-landing-page")
class HaLandingPage extends LandingPageBaseElement {
@@ -39,6 +41,8 @@ class HaLandingPage extends LandingPageBaseElement {
@state() private _coreCheckActive = false;
@state() private _progress = -1;
private _mobileApp =
extractSearchParam("redirect_uri") === "homeassistant://auth-callback";
@@ -60,7 +64,14 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar indeterminate></ha-progress-bar>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${this._progress > 0
? `${Math.round(this._progress)}%`
: nothing}</ha-progress-bar
>
`
: nothing}
${networkIssue || this._networkInfoError
@@ -126,6 +137,7 @@ class HaLandingPage extends LandingPageBaseElement {
import("../../src/components/ha-language-picker");
this._fetchSupervisorInfo(true);
this._fetchSupervisorJobsInfo();
}
private _scheduleFetchSupervisorInfo() {
@@ -138,6 +150,13 @@ class HaLandingPage extends LandingPageBaseElement {
);
}
private _scheduleFetchSupervisorJobsInfo() {
setTimeout(
() => this._fetchSupervisorJobsInfo(),
SCHEDULE_FETCH_JOBS_INFO_SECONDS * 1000
);
}
private _scheduleTurnOffCoreCheck() {
setTimeout(() => {
this._coreCheckActive = false;
@@ -165,7 +184,7 @@ class HaLandingPage extends LandingPageBaseElement {
// assume supervisor update if ping fails -> don't show an error
if (!this._coreCheckActive && err.message !== "ping-failed") {
// eslint-disable-next-line no-console
console.error(err);
console.error("Failed to fetch supervisor info", err);
this._networkInfoError = true;
}
}
@@ -175,6 +194,33 @@ class HaLandingPage extends LandingPageBaseElement {
}
}
private async _fetchSupervisorJobsInfo() {
try {
const jobsInfo = await getSupervisorJobsInfo();
const coreInstallJob =
jobsInfo.result === "ok"
? jobsInfo.data.jobs.find(
(job) => job.name === "home_assistant_core_install"
)
: undefined;
if (coreInstallJob) {
this._progress = coreInstallJob.progress;
} else {
this._progress = -1;
}
} catch (err: any) {
await this._checkCoreAvailability();
if (!this._coreCheckActive) {
this._progress = -1;
// eslint-disable-next-line no-console
console.error("Failed to fetch supervisor jobs info", err);
}
}
this._scheduleFetchSupervisorJobsInfo();
}
private async _checkCoreAvailability() {
try {
const response = await fetch("/manifest.json");
@@ -222,21 +268,27 @@ class HaLandingPage extends LandingPageBaseElement {
flex-direction: column;
gap: var(--ha-space-4);
}
ha-language-picker {
min-width: 200px;
}
ha-alert p {
text-align: unset;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
}
ha-language-picker {
margin-inline-start: calc(-1 * var(--ha-space-4));
}
ha-button {
margin-inline-end: calc(-1 * var(--ha-space-2));
}
ha-fade-in {
min-height: calc(100vh - 64px - 88px);
display: flex;
justify-content: center;
align-items: center;
}
ha-progress-bar {
--ha-progress-bar-track-height: 20px;
}
`,
];
}
-1
View File
@@ -1,3 +1,2 @@
[build.environment]
YARN_VERSION = "1.22.11"
NODE_OPTIONS = "--max_old_space_size=6144"
+47 -54
View File
@@ -27,86 +27,82 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.2",
"@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",
"@date-fns/tz": "1.4.1",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.5",
"@formatjs/intl-displaynames": "7.3.7",
"@formatjs/intl-durationformat": "0.10.11",
"@formatjs/intl-getcanonicallocales": "3.2.8",
"@formatjs/intl-listformat": "8.3.7",
"@formatjs/intl-locale": "5.3.7",
"@formatjs/intl-numberformat": "9.3.8",
"@formatjs/intl-pluralrules": "6.3.7",
"@formatjs/intl-relativetimeformat": "12.3.7",
"@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",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/mwc-base": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "2.4.1",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.1",
"@tsparticles/preset-links": "4.0.1",
"@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",
"barcode-detector": "3.1.3",
"barcode-detector": "3.2.0",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.0.0",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.3.0",
"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.2",
"intl-messageformat": "11.2.6",
"js-yaml": "4.1.1",
"idb-keyval": "6.2.5",
"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",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.3",
"marked": "18.0.5",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -118,7 +114,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"tinykeys": "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -129,21 +125,20 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
"@html-eslint/eslint-plugin": "0.61.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.3",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -155,17 +150,15 @@
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.6",
"@vitest/coverage-v8": "4.1.8",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.4.0",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -175,7 +168,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -186,24 +179,24 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "4.4.2",
"lint-staged": "17.0.4",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.7",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"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.15",
"terser-webpack-plugin": "5.6.0",
"tar": "7.5.16",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.3",
"typescript-eslint": "8.61.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.6",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -219,8 +212,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.14.1",
"packageManager": "yarn@4.16.0",
"volta": {
"node": "24.15.0"
"node": "24.16.0"
}
}

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260429.0"
version = "20260527.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+39
View File
@@ -18,7 +18,46 @@
"enabled": true,
"schedule": ["on the 19th day of the month before 4am"]
},
"customDatasources": {
"ha-core-python": {
"defaultRegistryUrlTemplate": "https://raw.githubusercontent.com/home-assistant/core/dev/.python-version",
"format": "plain"
}
},
"customManagers": [
{
"description": "Keep PYTHON_VERSION in sync with home-assistant/core (patch + minor)",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/"],
"matchStrings": ["PYTHON_VERSION: \"(?<currentValue>[^\"]+)\""],
"depNameTemplate": "python",
"datasourceTemplate": "custom.ha-core-python",
"versioningTemplate": "python"
},
{
"description": "Keep devcontainer image and requires-python in sync with home-assistant/core (minor only)",
"customType": "regex",
"managerFilePatterns": [
"/^\\.devcontainer/Dockerfile$/",
"/^pyproject\\.toml$/"
],
"matchStrings": [
"devcontainers/python:(?<currentValue>[\\d.]+)",
"requires-python = \">=(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "python",
"datasourceTemplate": "custom.ha-core-python",
"versioningTemplate": "python",
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
}
],
"packageRules": [
{
"description": "Group all Python version updates from home-assistant/core",
"matchDepNames": ["python"],
"matchDatasources": ["custom.ha-core-python"],
"groupName": "Python version"
},
{
"description": "MDC packages are pinned to the same version as MWC",
"extends": ["monorepo:material-components-web"],
+1 -1
View File
@@ -3,7 +3,7 @@
* @param arr - The array to get combinations of
* @returns A multidimensional array of all possible combinations
*/
export function getAllCombinations<T>(arr: T[]) {
export function getAllCombinations<T>(arr: readonly T[]): T[][] {
return arr.reduce<T[][]>(
(combinations, element) =>
combinations.concat(
+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;
+9
View File
@@ -114,6 +114,15 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
export const UNIT_C = "°C";
export const UNIT_F = "°F";
/** Length units. */
export const UNIT_IN = "in";
export const UNIT_KM = "km";
export const UNIT_MM = "mm";
/** Pressure units. */
export const UNIT_HPA = "hPa";
export const UNIT_INHG = "inHg";
/** Entity ID of the default view. */
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
+15 -1
View File
@@ -1,6 +1,20 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
// instead of an IANA zone name. Only accept values that are known IANA zones,
// matching the list used by ha-timezone-picker.
const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
+89 -21
View File
@@ -1,8 +1,17 @@
import { consume } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { entitiesContext, statesContext } from "../../data/context";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../types";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { LocalizeFunc } from "../translations/localize";
import { ensureArray } from "../array/ensure-array";
import { transform } from "./transform";
interface ConsumeEntryConfig {
@@ -18,6 +27,28 @@ const resolveAtPath = (host: unknown, path: readonly string[]) => {
return cur;
};
/** Reuse `previous` when every entry still references the same `HassEntity`. */
export const preserveUnchangedEntityStatesRecord = <
T extends Record<string, HassEntity | undefined>,
>(
previous: T | undefined,
next: T
): T => {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
if (Object.keys(previous).length !== nextKeys.length) {
return next;
}
for (const key of nextKeys) {
if (previous[key] !== next[key]) {
return next;
}
}
return previous;
};
const composeDecorator = <T, V>(
context: Parameters<typeof consume>[0]["context"],
watchKey: string | undefined,
@@ -55,27 +86,52 @@ export const consumeEntityState = (config: ConsumeEntryConfig) =>
);
/**
* Like {@link consumeEntityState} but for an array of entity IDs at
* `entityIdPath`. Resolves to a `HassEntity[]` containing one entry per
* currently-available entity (missing entities and non-string IDs are
* filtered out; original order is preserved).
* Like {@link consumeEntityState} but for one or more entity IDs at
* `entityIdPath` (a string or string array; wrapped with {@link ensureArray}).
* Resolves to a record keyed by entity ID containing the currently-available
* entities (missing entities and non-string IDs are filtered out). Returns the
* previous record when none of the selected entities changed.
*/
export const consumeEntityStates = (config: ConsumeEntryConfig) =>
composeDecorator<HassEntities, HassEntity[]>(
statesContext,
config.entityIdPath[0],
function (states) {
const ids = resolveAtPath(this, config.entityIdPath);
if (!Array.isArray(ids) || !states) return undefined;
const result: HassEntity[] = [];
for (const id of ids) {
if (typeof id !== "string") continue;
const state = states[id];
if (state !== undefined) result.push(state);
}
return result;
export const consumeEntityStates = (config: ConsumeEntryConfig) => {
const watchKey = config.entityIdPath[0];
const buildRecord = function (this: unknown, states: HassEntities) {
const ids = ensureArray(resolveAtPath(this, config.entityIdPath));
if (!ids || !states) return undefined;
const result: Record<string, HassEntity> = {};
for (const id of ids) {
if (typeof id !== "string") continue;
const state = states[id];
if (state !== undefined) result[id] = state;
}
);
return result;
};
return (proto: unknown, propertyKey: string) => {
const key = String(propertyKey);
const transformDec = transform<
HassEntities,
Record<string, HassEntity> | undefined
>({
transformer: function (this: unknown, states: HassEntities) {
const next = buildRecord.call(this, states);
if (next === undefined) {
return undefined;
}
const previous = (this as Record<string, unknown>)[
`__transform_${key}`
] as Record<string, HassEntity> | undefined;
return preserveUnchangedEntityStatesRecord(previous, next);
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({
context: statesContext,
subscribe: true,
});
transformDec(proto as never, propertyKey);
consumeDec(proto as never, propertyKey);
};
};
/**
* Consumes `entitiesContext` and narrows it to the
@@ -91,3 +147,15 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
return typeof id === "string" ? entities?.[id] : undefined;
}
);
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed the decorated property updates
* whenever the i18n context changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
internationalizationContext,
undefined,
({ localize }) => localize
);
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
interface EntityUnitStubConfig {
@@ -21,32 +21,24 @@ export const computeEntityUnitDisplay = (
stateObj: HassEntity | undefined,
config: EntityUnitStubConfig
): string => {
let unit;
if (
stateObj &&
!isUnavailableState(stateObj.state) &&
(config.attribute || stateObj.attributes.device_class !== "duration")
!stateObj ||
stateObj.state === UNAVAILABLE ||
stateObj.state === UNKNOWN ||
(!config.attribute && stateObj.attributes.device_class === "duration")
) {
// check for an explicitly defined unit in config
unit = config.unit;
if (!unit) {
if (!config.attribute) {
// use entity's unit_of_measurement
const stateParts = hass.formatEntityStateToParts(stateObj);
unit = stateParts.find((part) => part.type === "unit")?.value;
} else {
// use attribute's unit if available
const attrParts = hass.formatEntityAttributeValueToParts(
stateObj,
config.attribute
);
unit = attrParts.find((part) => part.type === "unit")?.value;
}
}
return unit ?? "";
return "";
}
return "";
// check for an explicitly defined unit in config
if (config.unit) {
return config.unit;
}
// otherwise derive from the entity's state or attribute
const parts = config.attribute
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return parts.find((part) => part.type === "unit")?.value ?? "";
};
+59
View File
@@ -0,0 +1,59 @@
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
export interface EntityLocation {
latitude: number;
longitude: number;
gpsAccuracy?: number;
}
const findFirstActiveZone = (
inZones: readonly string[],
states: HassEntities
): HassEntity | undefined => {
for (const zoneId of inZones) {
const zone = states[zoneId];
if (
zone &&
!zone.attributes.passive &&
typeof zone.attributes.latitude === "number" &&
typeof zone.attributes.longitude === "number"
) {
return zone;
}
}
return undefined;
};
export const getEntityLocation = (
stateObj: HassEntity,
states: HassEntities
): EntityLocation | undefined => {
const {
latitude,
longitude,
gps_accuracy: gpsAccuracy,
} = stateObj.attributes;
if (typeof latitude === "number" && typeof longitude === "number") {
return { latitude, longitude, gpsAccuracy };
}
if (computeStateDomain(stateObj) !== "person") {
return undefined;
}
const inZones = stateObj.attributes.in_zones;
if (!Array.isArray(inZones) || inZones.length === 0) {
return undefined;
}
const zone = findFirstActiveZone(inZones, states);
if (!zone) {
return undefined;
}
return {
latitude: zone.attributes.latitude,
longitude: zone.attributes.longitude,
};
};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE_STATES } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
@@ -253,7 +253,7 @@ export const getStatesDomain = (
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
result.push(UNAVAILABLE, UNKNOWN);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
+11 -5
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
@@ -8,14 +8,20 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
return UNAVAILABLE;
}
const validState = states.some(
(stateObj) => !isUnavailableState(stateObj.state)
const allUnavailable = states.every(
(stateObj) => stateObj.state === UNAVAILABLE
);
if (!validState) {
if (allUnavailable) {
return UNAVAILABLE;
}
const hasValidState = states.some(
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
if (!hasValidState) {
return UNKNOWN;
}
// Use the first state to determine the domain
// This assumes all states in the group have the same domain
const domain = computeStateDomain(states[0]);
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity/entity";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeDomain } from "./compute_domain";
export function stateActive(stateObj: HassEntity, state?: string): boolean {
@@ -19,7 +19,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
return compareState !== UNAVAILABLE;
}
if (isUnavailableState(compareState)) {
if (compareState === UNAVAILABLE || compareState === UNKNOWN) {
return false;
}
+13
View File
@@ -17,6 +17,19 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
* on load instead of cleaning up the stale dialog state by going back.
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
* Ensures all dialogs are closed before navigation.
* Returns true if navigation can proceed, false if a dialog refused to close.
+37
View File
@@ -0,0 +1,37 @@
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { RelatedResult } from "../../data/search";
export interface RelatedIdSets {
areas: Set<string>;
devices: Set<string>;
entities: Set<string>;
}
/**
* Build a set of related IDs for a given related result.
* @param related - The related result to build the sets from.
* @returns The related ID sets.
*/
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
areas: new Set(related?.area || []),
devices: new Set(related?.device || []),
entities: new Set(related?.entity || []),
});
/**
* Stable partition sort: related items float to the top,
* preserving relative order (e.g. Fuse score) within each group.
* @param items - The items to sort.
* @returns The sorted items.
*/
export const sortRelatedFirst = (
items: PickerComboBoxItem[]
): PickerComboBoxItem[] =>
[...items].sort((a, b) => {
const aRelated = Boolean(a.isRelated);
const bRelated = Boolean(b.isRelated);
if (aRelated === bRelated) {
return 0;
}
return aRelated ? -1 : 1;
});
@@ -0,0 +1,17 @@
/**
* @summary Truncates a string to `maxLength`, appending `ellipsis` only when it actually shortens the result.
* @param text The input string.
* @param maxLength Maximum length of the prefix kept before the ellipsis.
* @param ellipsis Suffix appended when truncation occurs.
* @returns `text` unchanged when its length is `<= maxLength + ellipsis.length`, otherwise `text.substring(0, maxLength) + ellipsis`.
*/
export const truncateWithEllipsis = (
text: string,
maxLength: number,
ellipsis = "..."
): string => {
if (text.length <= maxLength + ellipsis.length) {
return text;
}
return `${text.substring(0, maxLength)}${ellipsis}`;
};
@@ -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;
}
}
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -9,11 +8,10 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @summary
* Small status indicator dot used in automation/condition rows to surface the
* live evaluation result. Renders an optional tooltip with details on hover.
* live evaluation result.
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -21,8 +19,6 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -31,55 +27,38 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 12px;
height: 12px;
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
border: 3px solid;
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
}
:host([state="pass"]) #indicator:hover {
background-color: var(--ha-color-fill-success-loud-hover);
border-color: var(--ha-color-fill-success-loud-hover);
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-fill-warning-loud-resting);
}
:host([state="fail"]) #indicator:hover {
background-color: var(--ha-color-fill-warning-loud-hover);
border-color: var(--ha-color-fill-warning-loud-hover);
border-color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-fill-danger-loud-resting);
}
:host([state="invalid"]) #indicator:hover {
background-color: var(--ha-color-fill-danger-loud-hover);
border-color: var(--ha-color-fill-danger-loud-hover);
border-color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-fill-neutral-loud-resting);
}
:host([state="unknown"]) #indicator:hover {
background-color: var(--ha-color-fill-neutral-loud-hover);
border-color: var(--ha-color-fill-neutral-loud-hover);
border-color: var(--ha-color-neutral-60);
}
`;
}
@@ -128,7 +128,9 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: 0 0 0 var(--ha-space-3);
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -144,6 +146,8 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
@@ -157,11 +161,14 @@ 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);
}
:host([building-block]) ::slotted([slot="leading-icon"]) {
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
@@ -187,7 +194,6 @@ export class HaAutomationRow extends LitElement {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
::slotted([slot="header"]) {
overflow-wrap: anywhere;
+1 -1
View File
@@ -116,7 +116,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
ha-svg-icon {
:host([appearance="brand"]) ha-svg-icon {
color: var(--white-color);
}
`;
+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);
}
}
+104 -48
View File
@@ -14,6 +14,7 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -29,22 +30,59 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -66,9 +104,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public options?: ECOption;
@property({ attribute: false }) public options?: HaECOption;
@property({ type: String }) public height?: string;
@@ -614,7 +652,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: ECOption | undefined,
options: HaECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -634,7 +672,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -757,22 +795,34 @@ export class HaChartBase extends LitElement {
xAxis,
};
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
}
return options;
return options as ECOption;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -956,30 +1006,16 @@ export class HaChartBase extends LitElement {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: (data as LineSeriesOption["data"])!.map((v) =>
Array.isArray(v)
? [
v[0],
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
...v.slice(2),
]
: v
),
};
}
if (s.sampling === "minmax") {
if ((s as LineSeriesOption).sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -994,8 +1030,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
return {
...s,
result = {
...result,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -1003,11 +1039,10 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
};
} as HaECSeriesItem;
}
}
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
}
@@ -1344,8 +1379,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -1485,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 {
@@ -1523,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;
@@ -0,0 +1,41 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
) => TemplateResult | typeof nothing | null;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
(categories?: NetworkData["categories"]): HaECOption => ({
tooltip: {
trigger: "item",
confine: true,
+14 -6
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options = {
const options: HaECOption = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.hass=${this.hass}
@@ -101,14 +101,22 @@ export class HaSankeyChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
// Keep numbers and units left-to-right, even in RTL locales.
const formattedValue = html`<div style="direction:ltr; display: inline;">
${value}
</div>`;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${formattedValue}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${formattedValue}`;
}
return null;
};
+8 -8
View File
@@ -5,10 +5,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -25,8 +24,6 @@ export interface SunburstNode {
@customElement("ha-sunburst-chart")
export class HaSunburstChart extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public data?: SunburstNode;
@property({ attribute: false }) public valueFormatter?: (
@@ -50,13 +47,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options = {
const options: HaECOption = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,7 +68,10 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
};
private _createData = memoizeOne(
@@ -0,0 +1,41 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -12,8 +12,9 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -24,7 +25,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
private _hiddenStats = new Set<string>();
@@ -141,12 +141,11 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -177,52 +176,44 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
color: dataset.color,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
};
private _datasetHidden(ev: CustomEvent) {
@@ -1,11 +1,10 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -15,8 +14,9 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,42 +132,35 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
public willUpdate(changedProps: PropertyValues) {
if (
+1 -1
View File
@@ -167,7 +167,7 @@ export class StateHistoryCharts extends LitElement {
)}`}
${this.syncCharts && this._hasZoomedCharts
? html`<ha-button
size="large"
size="l"
class="reset-button"
@click=${this._handleGlobalZoomReset}
>
+93 -82
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -34,12 +34,13 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
@@ -126,7 +127,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _hiddenStats = new Set<string>();
@@ -251,91 +252,101 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
startTime = new Date(param.value[0]);
}
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
options
)}${unit}`;
this.hass.config
);
}
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
};
private _createOptions() {
@@ -1,13 +1,14 @@
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
@@ -24,7 +25,9 @@ import type { DataTableSettingsDialogParams } from "./show-dialog-data-table-set
@customElement("dialog-data-table-settings")
export class DialogDataTableSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _params?: DataTableSettingsDialogParams;
@@ -117,7 +120,7 @@ export class DialogDataTableSettings extends LitElement {
return nothing;
}
const localize = this._params.localizeFunc || this.hass.localize;
const localize = this._params.localizeFunc || this._localize;
const columns = this._sortedColumns(
this._params.columns,
@@ -172,7 +175,7 @@ export class DialogDataTableSettings extends LitElement {
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${this.hass!.localize(
.label=${localize(
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
{ title: typeof col.title === "string" ? col.title : "" }
)}
+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;
@@ -1,10 +1,12 @@
import { consume } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -12,7 +14,7 @@ import {
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerValueRenderer } from "../ha-picker-field";
@@ -46,13 +48,14 @@ export abstract class HaDeviceAutomationPicker<
}
private _localizeDeviceAutomation: (
hass: HomeAssistant,
localize: LocalizeFunc,
states: HassEntities,
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
private _fetchDeviceAutomations: (
hass: HomeAssistant,
callWS: CallWS,
deviceId: string
) => Promise<T[]>;
@@ -127,7 +130,8 @@ export abstract class HaDeviceAutomationPicker<
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass,
this.hass.localize,
this.hass.states,
this._entityReg,
automation
);
@@ -162,7 +166,12 @@ export abstract class HaDeviceAutomationPicker<
);
const text = automation
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
@@ -172,9 +181,9 @@ export abstract class HaDeviceAutomationPicker<
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
sortDeviceAutomations
)
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
+3 -5
View File
@@ -107,17 +107,15 @@ export class HaDevicePicker extends LitElement {
excludeDevices?: string[],
value?: string
) =>
getDevices(
this.hass,
configEntryLookup,
getDevices(this.hass, configEntryLookup, {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value
)
value,
})
);
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
+18 -7
View File
@@ -2,12 +2,17 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-sortable";
import "./ha-entity-picker";
import type { HaEntityPicker } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@@ -151,7 +156,7 @@ class HaEntitiesPicker extends LitElement {
`;
}
private _entityMoved(e: CustomEvent) {
private _entityMoved(e: HASSDomEvent<HASSDomEvents["item-moved"]>) {
e.stopPropagation();
const { oldIndex, newIndex } = e.detail;
const currentEntities = this._currentEntities;
@@ -178,7 +183,7 @@ class HaEntitiesPicker extends LitElement {
return this.value || [];
}
private async _updateEntities(entities) {
private async _updateEntities(entities: string[]) {
this.value = entities;
fireEvent(this, "value-changed", {
@@ -186,9 +191,12 @@ class HaEntitiesPicker extends LitElement {
});
}
private _entityChanged(event: ValueChangedEvent<string>) {
private _entityChanged(
event: ValueChangedEvent<string> &
HASSDomCurrentTargetEvent<HaEntityPicker & { curValue: string }>
) {
event.stopPropagation();
const curValue = (event.currentTarget as any).curValue;
const curValue = event.currentTarget.curValue;
const newValue = event.detail.value;
if (
newValue === curValue ||
@@ -206,13 +214,15 @@ class HaEntitiesPicker extends LitElement {
);
}
private async _addEntity(event: ValueChangedEvent<string>) {
private _addEntity(
event: ValueChangedEvent<string> & HASSDomCurrentTargetEvent<HaEntityPicker>
) {
event.stopPropagation();
const toAdd = event.detail.value;
if (!toAdd) {
return;
}
(event.currentTarget as any).value = "";
event.currentTarget.value = "";
if (!toAdd) {
return;
}
@@ -239,6 +249,7 @@ class HaEntitiesPicker extends LitElement {
}
.entity ha-entity-picker {
flex: 1;
min-width: var(--ha-entities-picker-entity-min-width, auto);
}
.entity-handle {
padding: 8px;

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