Compare commits

..
190 Commits
Author SHA1 Message Date
Petar PetrovandClaude Opus 5 eebc57d89a Open the battery entity from the device page header (#55734)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-11-07 13:09:55 +01:00
renovate[bot] 63b474f43c Update dependency fullcalendar to v7.1.4 (#55730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 10:18:04 +01:00
renovate[bot] d5cc973c22 Update dependency @versatiles/style to v8.1.1 (#55715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 11:17:46 +02:00
renovate[bot] 00e76021ce Update dependency temporal-polyfill to v1.0.7 (#55731)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 10:08:44 +02:00
Maarten Lakerveld fb88d66421 Add My redirect for the action history page (#55705)
Lets my.home-assistant.io link to Settings > Tools > Action history
via tools_action_history.
2026-11-06 16:36:31 +01:00
renovate[bot] 23b161d257 Update dependency @rslib/core to v1.0.5 (#55710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 17:28:14 +02:00
123a32dd81 Add experimental @home-assistant/components package (#55652)
* Add hac-card and hac-header as first components of the hac library

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Move hac components into a publishable workspace package built with Rslib

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Rework hac package: tree-shakable entry points, safe registration, standalone theming

- Separate component classes from registration: the root entry exports
  classes only, ./card, ./header and ./all register elements. Named
  imports from the root are now tree-shaken, and sideEffects lists only
  the registering modules (dist and src, as the repo resolves to src).
- Skip registration when a tag is already defined, so Home Assistant and
  custom cards can ship the library side by side.
- Fix hac-header empty-slot handling: :has(::slotted()) is not supported
  by browsers, so empty icon and action slots added gaps.
- Expose the header title as a heading with a configurable level.
- Add fallbacks for all theme variables so components render outside
  Home Assistant, lay out footer actions, drop overflow clipping.
- Resolve the package to its sources via a hac-source export condition
  instead of hand-written aliases, add it as a workspace dependency and
  include it in lit-analyzer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Add hac as a workspace dependency of the frontend

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Add global, scoped and dynamic loading modes to hac components

Each component now lives in components/<name>/ with its class, a
version.ts, a CHANGELOG.md and three entry points:

- @home-assistant/hac/<name>: registers globally, as Home Assistant does
- <name>/scoped: registers the bundled copy in a scoped registry
- <name>/dynamic: uses Home Assistant's copy when its version is
  compatible, the bundled copy otherwise

ScopedElementsMixin (@home-assistant/hac/scoped) gives a Lit element its
own registry and supports both the standard API and the older API of the
scoped registry polyfill that Home Assistant loads. Without either, it
falls back to global registration.

Home Assistant publishes its components with versions and lazy loaders
at startup. The HTML creates a pending slot first, so extra modules that
load in parallel with the app wait for it instead of missing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Support plain custom elements in scoped mode and require scoped elements

- Add createScopedElementRegistry, attachScopedShadow and
  createScopedElement, so plain custom elements can use scoped and
  dynamic components, not just Lit elements.
- ScopedElementsMixin builds on them and now takes the elements as a
  required argument: forgetting them is a type error instead of silently
  rendering unknown elements. Plain JavaScript gets a clear runtime error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Add button group and close option for hac-header

- Add the button group with hac-button and hac-button-close, a plain
  icon button with a close icon.
- hac-header gets show-close and close-label; the close button fires
  hac-close.
- Components declare the hac elements they render as static
  dependencies. global registers them globally, so Home Assistant uses
  no scoped registries; scoped and dynamic use a variant with its own
  registry of bundled dependencies, so a component and its dependencies
  always come from the same copy.
- Exclude generated package output from the root type check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Show a dismissable greeting card on the profile page using hac

Trial use of the hac components inside Home Assistant: a hac-card with
a hac-header that has show-close enabled, localized title and close
label. Dismissing hides it until the page is reloaded.

Using it surfaced two things:
- A card with only a header still rendered the padding of its empty
  content area, so hac-card now omits it.
- lit-analyzer only follows imports one level into node_modules, where
  the workspace package resolves, so files using hac entry points failed
  its missing-import check when linted alone, as the pre-commit hook
  does. Allow one more level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Keep the patches comment next to PATCHES_DIR

The hac alias block was inserted between the comment and the constant it
describes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the hac pages from the gallery

The library is a concept for now and should get its own documentation
later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Give every hac element its own entry points and drop the all export

Groups now only organize elements: a group's directory holds its default
element and variants live in subdirectories, mirroring the import paths.
@home-assistant/hac/button registers hac-button only, and
@home-assistant/hac/button/close registers hac-button-close, each with
its own version, changelog, scoped and dynamic entry points.

The all export registered every bundled copy globally, which a custom
card could use to replace Home Assistant's own components, and nothing
used it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Rename the package to @home-assistant/components

The package name says what it is; hac- stays the prefix of the element
tags and classes. The export condition that resolves workspace packages
to their sources is renamed from hac-source to the neutral ha-source, so
future packages can use it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Load bundled components in dynamic mode only when they are needed

dynamic entry points now import only the element's version up front and
load the bundled component with a dynamic import when Home Assistant
cannot provide a compatible copy, so bundlers split it into a chunk that
is not downloaded otherwise.

The static version on the component classes is removed: the host
publishes its versions in the provider list, so version.ts is the only
source of truth.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Fall back to the scoped entry point in dynamic mode

Each dynamic entry point loads its bundled element through a named
loadScoped function that imports the element's scoped entry point, so
dynamic uses exactly what scoped mode registers and createDynamicLoader
no longer builds the scoped variant itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Drop the registration note from the component docs

How elements are registered is the same for every component and is
documented once in the package README.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Move the slot checks of hac-card and hac-header into a shared helper

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Group the utils of the components package by purpose

registration/ holds what defines elements (global, scoped, dynamic),
element/ the helpers components use themselves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Define each element once with its tag name next to its version

- version.ts becomes info.ts with TAG_NAME and VERSION, the cheap module
  that dynamic, global, HA's provider and dependent components read the
  tag name from. Only HTMLElementTagNameMap and Lit templates keep the
  literal, as the tooling needs it there.
- scoped and dynamic entry points export one definition object, e.g.
  hacCard = { tagName, element }, instead of a single-entry map.
  ScopedElementsMixin, createScopedElementRegistry and static
  dependencies take lists of definitions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Drop the pending provider slot from the HTML

Only extra modules can run before the app publishes its components, and
then they simply use their bundled copies. Not worth the inline script
while the library is a concept: the host now just publishes the list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the root entry point of the components package

Nothing used it and it exposed the registration internals as public API.
Every element, the scoped helpers and the provider have their own entry
points; class types come from an element's scoped entry point with
import type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Clean up the components package API and naming

- Drop the Hac prefix from library-level names, which are not elements:
  ElementDefinition, ElementLoader, ComponentClass, ProvidedComponent(s),
  PROVIDER_KEY. HA's provider file becomes components-provider.ts.
- /scoped no longer exports scopedRegistrySupport, and the mixin no
  longer adds a static scopedRegistry; both were internals.
- scopedVariant uses the mixin's subclass instead of wrapping it again.
- Rename utils/registration/version.ts to compatibility.ts, as elements
  keep their version in info.ts, and keep ComponentClass with the other
  definition types.
- Update the outdated Rslib config comment: only global.ts registers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Clarify where to create the scoped registry

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the per-element changelogs from the concept

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the dynamic loading mode from the components package

The concept starts with global and scoped only. Dynamic mode, with the
provider list HA publishes, the version compatibility check and loader
support in the registry helper, can be added later.

Elements keep their VERSION in info.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Regenerate the lockfile on top of dev for the components package

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Use the semantic color tokens in the components

The components use --ha-color-* tokens (surface, on-surface, text,
border, primary fill, focus) instead of the legacy theme variables, with
the light values as fallback outside Home Assistant. User theme colors
still apply because HA derives the --ha-color-primary-* palette from
them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Reduce the components package to the card for the concept

Remove the button group and the header. The profile greeting is a plain
hac-card now, as it had no close button to dismiss it anymore. The
dependency mechanism stays, documented with a generic example, for the
first element that renders other elements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the dependency mechanism from the components package

Nothing uses it since the header and buttons are gone. The card now
registers itself with define, and its scoped entry point exports the
class directly. It can come back with the first element that renders
other elements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Use hac-card for the long-lived access tokens in the profile

The card header is a heading in the header slot, the content needs no
wrapper, and the create button sits in the footer slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* refactor: update pnpm workspace settings and improve profile dashboard layout

- Excluded 'rs-module-lexer' from allowed builds in pnpm workspace configuration.
- Adjusted footer alignment in the long-lived access tokens card for better layout.
- Removed unnecessary 'hac-card' from the profile dashboard, simplifying the structure.
- Updated CSS to target only 'ha-card' for styling, enhancing consistency.
- Removed greeting title translation to streamline user experience.

* fix: update caching paths and improve long-lived tokens card styles

* fix: update theming fallback order and improve hac-card styles

* fix: replace `replace` with `replaceAll` for consistent wildcard handling in component source aliases

* copilot

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-06 16:04:12 +01:00
WendelinandClaude Opus 5.5 4dc713abe6 Add categories and descriptions to the create helper dialog (#55701)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-06 13:55:47 +01:00
WendelinandClaude Opus 5.5 b9182e3ea6 Fix bright card outlines on Shelly Wall Display (#55704)
* Keep rgba() colors in the legacy CSS build

Raising the legacy floor to Chrome 67 lets lightningcss rewrite rgba()
to 8-digit hex (#rrggbbaa), since Chrome 62 supports it. Kiosk webviews
like the Shelly Wall Display get the legacy build but can't parse that,
so var(--divider-color) borders on cards, toggles and buttons fell back
to currentColor and showed as bright outlines, the same symptom as
#52986. Always compile hex alpha colors to rgba() in legacy builds, for
both css`` templates and imported stylesheets. Modern builds are
unchanged.

Also drop the loader's redundant /* global */ header, which ESLint now
rejects because module and require are already built-in globals in
.cjs files.

Fixes #55699

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGHbhPo6Q2xNuKcJen

* Also keep rgba() colors in the legacy service worker build

The template loader runs with the legacy-sw browserslist env for the
service worker layer, which an exact match on legacy missed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGHbhPo6Q2xNuKcJen

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 12:06:46 +01:00
Christian Wiedel 9bd57fa122 Fix grouped data tables stuttering when scrolling in Safari (#55702)
* Fix grouped data tables stuttering when scrolling in Safari

Group header actions are rendered by a callback instead of a slot per group
header. The virtualizer adds and removes header rows while scrolling, and
WebKit is slow to add and remove slots.

Fixes #55636

* Keep group header actions when the data or the pinned group changes

Group header rows are keyed by their group, so an open category menu stays open when the data refreshes. The sticky header is keyed by the pinned group, so its actions don't act on the next group's category.

* Keep group header keys apart from row keys

A group named like a row's id gave the header and the row the same virtualizer key. Prefix the keys so they can't collide.

* Don't call the group header actions slotted in the styles
2026-11-06 11:31:48 +01:00
renovate[bot] 4af7503112 Update dependency @rspack/core to v2.3.1 (#55703) 2026-11-06 10:02:58 +00:00
Jump-333 984e63dcd0 Individual devices to gas in energy dashboard (#55533)
* Add individual devices to gas in energy dashboard

* Fix copilot issues

* Fix conflicts

* Fix issues

* Fix issues

* Fix more issues

* Centralize gas and water dialogs and sankey cards

* Merge conflicts in energy-overview-view-strategy

* Merge conflicts

* Merge conflicts

* Add name and description

* Fix issues
2026-11-06 12:00:52 +02:00
Paul Bottein f90ed06080 Fix form field visibility depending on field order (#55681) 2026-11-06 10:52:25 +01:00
b176b8226b Update agents file to mention how to handle CSS on onboarding screens (#55631)
Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
Co-authored-by: bgoncal <[email protected]>
Co-authored-by: maartenla <[email protected]>
Co-authored-by: Bruno Pantaleão Gonçalves <[email protected]>
2026-11-06 09:24:58 +00:00
Zubir Jamal 3f3e9297f7 Add Malay (Bahasa Melayu) to the frontend (#55693) 2026-11-06 10:11:02 +01:00
Markus TuominenandClaude Fable 5.1 83028028e8 Add icons to the lists on the area page (#55700)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-06 09:02:03 +00:00
Petar Petrov 37d7c6144c Add expand_legend option to the distribution card (#55698) 2026-11-06 08:31:22 +00:00
Petar Petrov f469f3eeae Fit narrow chart legends to three rows instead of two (#55697) 2026-11-06 08:30:51 +00:00
WendelinandClaude Opus 5.5 069e87c913 Don't show the via device as area in the devices table (#55674)
* Don't show via device as area in devices table

The Area and Floor columns fell back to the via device's area or name
when a device had no area. For protocol integrations like ZHA this put
the coordinator's name in the Area column of every unassigned end
device. Only use the device's own area or the one inherited from its
parent device. The device picker keeps the via device hint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGXydVGvZF1ETnrXih

* Add 'connected via' translation and update device dashboard to display via device information

* remove comment

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 08:16:18 +02:00
renovate[bot] 46bdbee8f3 Update dependency marked to v18.1.3 (#55684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:50:46 +01:00
renovate[bot] 68def911a6 Update tsparticles to v4.5.1 (#55685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:50:30 +01:00
renovate[bot] a8fdb99317 Update pnpm to v12.17.0 (#55695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:50:06 +01:00
renovate[bot] 3d1bf61683 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.1 (#55694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:49:45 +01:00
Simon Lamon 5cfdff0e21 Google Assistant => Google Home (#55683) 2026-11-05 16:17:46 -05:00
Christian Wiedel 54cefeb816 Fix wrapped flow dialog titles overlapping the subtitle (#55661) 2026-11-05 21:09:50 +01:00
renovate[bot] 4458205273 Update vitest monorepo to v5.1.0 (#55680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 19:28:39 +01:00
Paul Bottein 0c2de46f11 Fix truncated buttons in the voice assistant setup (#55677) 2026-11-05 18:36:57 +01:00
Aidan Timson 6bef900455 Fix the max-age of the demo and cast cache headers (#55676) 2026-11-05 18:36:36 +01:00
Aidan Timson 438bfa2710 Let embed entities set their entity registry options (#55675) 2026-11-05 17:26:47 +01:00
puddly 96864e951a Allow the ESPHome serial proxy URL to be null (#55665)
* Allow the ESPHome serial proxy URL to be None

* Clarify comment
2026-11-05 18:08:22 +02:00
renovate[bot] a1355e5116 Update babel monorepo to v8.1.0 (#55667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 17:19:21 +02:00
Petar Petrov ae4366020c Choose the area when creating a helper without a device (#55646)
* Choose the area of a helper that has no device

Storage helpers get an Area field in their create form. Config flow
helpers get an area picker on the success step when any of their
entities has no device; the area is set on all of them. The redirect
after creating then uses the entity ID as regenerated for the area.

* Count the area as an unsaved change in the helper dialog

* Disable the helper area picker while saving
2026-11-05 15:03:47 +00:00
renovate[bot] 7f73d29b2f Update dependency @versatiles/style to v8 (#55668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 15:46:36 +01:00
Petar Petrov ebf453ccc7 Show the new helper after creating it (#55628)
* Open the new helper after creating it

From the helpers page, a new helper now opens its device page when its
entities belong to one device, or its more-info dialog otherwise.

* Open the right entity after creating a helper

Look up the storage helper's entity by its unique ID, because its entity
ID can differ from the helper ID. Start the config entry lookup when the
success step shows and skip the redirect if the dialog closed meanwhile.

* Ignore the helper lookup after the dialog closes or reopens

* Ignore a helper created after its dialog closed or reopened
2026-11-05 15:23:30 +01:00
TimothyandClaude Opus 5.5 a0d9afe695 Keep dialogs while navigating (#55627)
* Keep dialogs while navigating

* Apply suggestions

* Keep navigate async when keeping dialogs

Skipping the await committed the navigation synchronously, so a navigate()
called right after it saw the committed count change and was dropped as
stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 15:11:49 +01:00
Javier Torres c42d048736 Wrap long text in cloud remote access settings list (#55660) 2026-11-05 12:34:55 +00:00
Stefan AgnerandClaude Opus 5.5 2f62ed5f73 Improve app panel ingress error messages (#55655)
Give the raw Supervisor client error some context when loading the app
info fails, show a dedicated message when a non-admin opens an
admin-only app, and include the underlying error when creating the
ingress session fails.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 14:19:46 +02:00
Aidan Timson 8b6caeecdd Render full dashboards with their tabs in the demo embed (#55640)
* Add an embedded mode to hui-root that leaves the page's URL alone

* Add a dashboard component to the demo embed

* Keep embedded dashboards' back navigation, height and sidebar tabs inside the embed

* Follow anchors, replacements and new configs in embedded dashboards

* Go back past a followed back_path in embedded dashboards

* Isolate embedded view containers so view backgrounds show

* Match decoded and aliased views and skip empty embedded dashboards

* Read the sidebar tab once sections views have their dashboard

* Declare the navigate events in HTMLElementEventMap

* Skip the Lovelace info fetch and its mock in embedded dashboards
2026-11-05 12:59:44 +02:00
Bram KragtenandClaude Opus 5.5 4800b4f7e9 Deploy production when the Demo and Cast workflows are run on master (#55657)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:59:09 +02:00
Paul Bottein c7afc3391c Add history and statistics chart pages to the gallery (#55651) 2026-11-05 12:15:29 +02:00
Aidan Timson 3e9490e547 Render views and dashboard strategies in the demo embed (#55639)
* Add a view component to the demo embed

* Show only the latest generated dashboard in the demo embed

* Stop stale and repeated strategy results in embedded views

* Draw view themes and backgrounds in embedded views
2026-11-05 09:50:00 +00:00
Aidan Timson 7a1cd6bbfe Render sections in the demo embed, with a device registry (#55638)
* Share embed provider lookup and theme in a base element

* Add devices to the demo embed provider

* Add a section component to the demo embed

* Draw section backgrounds and accept more section configs in the demo embed
2026-11-05 09:49:59 +00:00
Paul Bottein a2c3147d33 Fix area controls freezing large dashboards (#55650) 2026-11-05 10:44:35 +02:00
Petar Petrov 7039e3ad9f Open the new device or entry after adding an entry from an integration page (#55630)
Keep navigateToResult when adding an entry for a specific domain
2026-11-05 09:33:55 +01:00
JonathanandXyolyp e97745d7d5 Filter the device classes by the chosen unit (#55422)
* Filter the device classes by the chosen unit

* Keep unsupported device classes selectable

* Only preselect a required device class and keep the default rows

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:57:19 +02:00
Mr-Hell 790c957405 Add device class column, search and filter to entities table (#55366)
* Add device class column and filter to entities table

* Localize device classes and preserve redirect filters

* Fix import conflict after rebase

* Refactor device class filter based on review feedback

* Avoid rebuilding device class options while filter is collapsed

* Address final device class review feedback

* Use shared device class translation key for filter
2026-11-05 06:26:31 +00:00
JonathanandXyolyp f740091579 Prevent a double Finish click in the config flow success step (#55616)
* Prevent a double Finish click in the config flow success step

* Keep only the double-click test and stub ha-input

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:19:55 +02:00
Simon Lamon f76c88dd9f Clean up check list item based on mwc-list (#55643) 2026-11-05 08:19:18 +02:00
renovate[bot] a428d61cdb Update formatjs (#55644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 01:13:34 +00:00
Bram KragtenandClaude Opus 5.5 fcf7eeae46 Use the preloaded Lovelace config again (#55626)
Since the "lovelace" dashboard got a real url_path (#28446), the panel threw
the config preloaded in core.ts away and fetched it a second time on every
page load at / or /lovelace/.

Use the preload for the "lovelace" dashboard and fetch again if it failed.
Only preload the config on /lovelace/, as / opens the default panel, which
is not known yet and is home for new installs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 20:47:32 +01:00
6d2817b7cf Migrate the Cast launcher view picker (#55634)
* Migrate the Cast launcher view picker from ha-list

The view picker in the Cast launcher was the last use of the mwc-based
list outside of open pull requests. It is now a single-select
ha-list-selectable.

Views without an icon get the dashboard icon in the start slot, which
mwc never showed because it was put in a slot ha-list-item does not
have. As every row has an icon when any view has one, the hide-icons
attribute is no longer needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Enhance view selection in HcCast component with accessible header and click handling

* Type Cast view click event target

Co-authored-by: wendevlin <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-04 19:46:53 +00:00
Maarten Lakerveld 640e4e2f94 Resolve security and maintenance entities through one helper per page (#55609)
The security page, the maintenance page, the Home summary badges and
the Home overview each rebuilt the same filter functions and ran
findEntities over every state. Add findFilteredEntities, which takes
the states and registries as one object, and a findSecurityEntities
and findMaintenanceEntities wrapper so every consumer resolves a
page's entity set the same way. No behaviour change.
2026-11-04 20:32:36 +01:00
renovate[bot] 6670139c02 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.0 (#55642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 20:13:39 +02:00
Erwin DounaandCopilot Autofix powered by AI 93eb44b041 Offer log download after disabling debug logging (#55621)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-04 16:49:28 +01:00
Joost LekkerkerkerandClaude Opus 5.5 ba6067f048 Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

The GitHub open issues count includes pull requests. Rename the
translation keys and update the English strings so the label matches
the number shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Update marketplace tests for the renamed open issues keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 17:22:53 +02:00
renovate[bot] cd866a497a Update dependency globals to v17.14.0 (#55633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 17:20:30 +02:00
98a8483426 onboarding-location migrate list (#55467)
* onboarding-location migrate list

* Apply batched suggestions from code review

Co-authored-by: Aidan Timson <[email protected]>

* fix tests and review

---------

Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-04 15:39:56 +02:00
33af67bafe Respect safe area insets on the onboarding page (#55623)
* Respect safe area insets on the onboarding page

* Update src/html/onboarding.html.template

Co-authored-by: Wendelin <[email protected]>

* Update src/html/onboarding.html.template

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-11-04 12:58:51 +00:00
Bram KragtenandClaude Opus 5.5 027d9578ec Match the entity filter in the states tool to the entity column (#55622)
The entity filter matched the full name and the friendly name, so typing a
device name returned entities whose entity column showed no match. It now
matches the entity ID and the name the entity column shows. When the device
column is hidden, it still matches the full name.

Fixes #55451

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:05 +02:00
Bram KragtenandClaude Opus 5.5 5fef8f3a68 Refetch the cached lovelace dashboard after it changed elsewhere (#55624)
The "lovelace" panel is cached by the panel resolver, so it should stay
subscribed to config updates while the user is on another page. Since the
urlPath getter returns "lovelace" instead of null, the null check in
disconnectedCallback always unsubscribed it, and nothing resubscribed on
reconnect. Changes saved from another page, like adding a device section
from the device page, only showed after a browser refresh.

Also reset the refetch flag once used, so returning to the dashboard
without a change doesn't refetch the config again, and let a pending
refetch take precedence over the locale rebuild, which would otherwise
rebuild from the stale config.

Fixes #55440

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:53:35 +02:00
Wendelin 18e65c1940 Show a loading skeleton while the card editor loads (#55625) 2026-11-04 11:22:55 +00:00
29906d6f83 Let core work out what target exclusions take out (#55585)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-04 11:00:40 +01:00
WendelinandClaude Opus 5.5 952c227370 Migrate the new automation dialog and zones list (#55593)
* Migrate the new automation dialog and zones list from ha-list

Moves the new automation and script dialog and the zones list from the
mwc-based ha-list and ha-list-item to ha-list-base and
ha-list-item-button, handling clicks instead of request-selected.

- Render the get blueprints link as a list item button with an href
  inside the blueprints list
- Keep the zone edit buttons in the row's end slot, as their handlers
  already stop the click from reaching the row
- Drop the request-selected handling left in the more info dialog and
  the webhook trigger, where nothing fires it anymore
- Remove the now unused request-selected helper
- Point the automation e2e helper at the new list items

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Pass the blueprint path and zone id as data attributes

Both are plain strings, so data attributes avoid untyped ad hoc
properties on the list items and their any casts in the handlers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Drop the unused entityId property from the zone edit button

Nothing reads it: the edit handler only checks noEdit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Drop the redundant display block on the zones list

ha-list-base is already a block element.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Hide the home zone edit button on narrow screens

The old list item hid its meta slot for the home zone on narrow
screens, where clicking the row already edits it. The new list item
has no such switch, so leave the button out instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Type the list item click handlers with their current target

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
2026-11-04 11:43:24 +02:00
pcan08andClaude Opus 5.5 173c60e57b Let users choose buttons or a slider in the fan speed tile card feature (#55266)
* Allow fans with up to 11 speeds to show speed buttons

Keep showing buttons by default up to 6 speeds, and add helpers to
resolve the speed style (buttons or slider) for a fan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add a switch between buttons and slider for fan speed in more-info

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add a style option to the fan speed card feature

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add fans with more speeds to the gallery

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Use contexts in the fan speed card feature editor

Read the fan state and localize from contexts instead of the full hass
object, so the editor only updates when this fan or the language changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Address review feedback on fan speed style

- Only show the "too many speeds" info in the feature editor when the
  fan has too many speeds for buttons, not for an off/on fan.
- Reuse the numeric input style labels and align the more-info switch
  labels with covers.
- Show fractional steps in round-trip test names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep buttons for off/on fans whatever style is requested

Fans that do not offer a style choice now always resolve to their
default style, so an off/on fan configured with the slider style no
longer gets stuck on a slider with a disabled selector.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep dense fan speed buttons from overflowing on short screens

Give dense vertical speed buttons a minimum height based on the number
of segments, so they no longer overflow into the mode switch when the
viewport is short.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Remove the fan speed style switch from more-info

Keep this change to the card feature, as asked in review. The more-info
dialog goes back to its previous behavior: buttons up to 6 positions,
a slider above.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Show a row of segments for the fan speed buttons style

The buttons picture in the fan speed feature editor showed the numeric
input stepper. Use a new picture of a segmented row instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Fall back to a slider when fan speed buttons do not fit

Measure the fan speed feature width like the favorite features and show
the slider when the buttons would be too narrow for their labels. Off
and 6 speeds still fit as buttons in a half-width card, while a fan with
10 speeds no longer cuts its last label. The editor now explains this
below the style option.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Clarify fan speed feature examples and helper logic

- Explain the button minimum width next to the constants instead of in
  the test.
- Read the disabled state from the schema for the editor helper text.
- Describe the default slider and the width fallback in the gallery
  headings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Use a 24px minimum for fan speed buttons

Buttons in card features have no gap between them, so each one must be
at least 24px wide to meet the WCAG 2.2 AA target size. This also fits
two-digit speeds, so a single minimum replaces the previous pair.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Refine tmp fan entity and config in gallery

* Keep the fan speed button minimum width in the card feature

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 11:36:10 +02:00
Paul Bottein ef134330f3 Add an option to include pre-releases in Marketplace repositories (#55567) 2026-11-04 09:24:07 +00:00
WendelinandClaude Opus 5.5 c75386ab33 Add descriptive aria-labels to picker filter chips (#55592)
* Give picker section filter chips a descriptive aria-label

Screen readers announced the section chips in quick search and the
target picker only by their visible text (e.g. "Navigate"), which gives
no hint that the button filters the results. Label them as
"Filter: <section>" instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eFvrHAAoLcdEeqDAUg

* Fix fallback for section label localization in ha-picker-combo-box

---------

Co-authored-by: Claude <[email protected]>
2026-11-04 10:16:45 +01:00
Franck Nijhof 61b433f912 Add Marketplace download source menu item for the China mirrors (#55615) 2026-11-04 10:16:34 +01:00
renovate[bot] 16ed504c66 Update formatjs (major) (#55619)
Update formatjs

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 06:37:58 +00:00
renovate[bot] e9550ffe52 Update formatjs (#55618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 08:28:13 +02:00
Aidan Timson 2c6cf8e45b Fix the demo embed for the contributors site (#55596) 2026-11-03 15:49:56 +01:00
0dcfc247a7 Add a navigate button to the map overview (#55600)
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 15:43:08 +01:00
Maarten Lakerveld 2ab1f187c5 Add requested neon map theme (#55603) 2026-11-03 15:39:25 +01:00
56d6f1c571 Migrate last checkbox list (#55453)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-03 15:35:51 +01:00
Aidan Timson f169ae79f5 Add a loading state to the generic picker (#55561) 2026-11-03 15:17:47 +01:00
Josef Zweck 599089a2d3 WTH: Show when long-lived access tokens were last used (#55589) 2026-11-03 15:10:45 +01:00
Maarten Lakerveld 2af451338f Stop recomputing active system domains on every state change in the add element dialog (#55586) 2026-11-03 15:00:45 +01:00
karwosts 2ba9a1fc61 Add segments editor to bar gauge (#55601) 2026-11-03 14:51:26 +01:00
dependabot[bot] 96e525f7a3 Bump postcss-selector-parser from 7.1.5 to 7.1.7 (#55598)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.5 to 7.1.7.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/7.1.5...7.1.7)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 7.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-03 12:31:49 +00:00
WendelinandClaude c42dac197f Fix Copilot code review failing on the setup steps (#55597)
Inline setup steps in the Copilot setup workflow

Copilot runs copilot-setup-steps.yml in a context that can't resolve
`$/` self-repository actions, so Copilot code review fails with
"Unable to resolve self-reference '$/'". Inline the setup action's
steps so the workflow only uses pinned remote actions; the actions
lockfile entries stay the same.


Claude-Session: https://claude.ai/code/session_018Kv7eHPGHei6YP9qBPGw1D

Co-authored-by: Claude <[email protected]>
2026-11-03 12:20:56 +00:00
Alexander Hass 8fbae6346d Fix README images and links with a relative path in the Marketplace (#55525)
* Keep relative README addresses for the Marketplace rewrite

Images and links in a README with a plain relative path, such as `images/logo.png`, were broken on the Marketplace repository page. xss empties every `src` and `href` that does not start with a scheme, `/`, `./`, `../` or `#`, so the path was gone before `rewriteHtmlUrls` could point it at the repository.

`renderMarkdown` takes `allowRelativeUrls`, which `ha-markdown-element` sets when it rewrites the addresses afterwards. Such an address of a link, image or source is kept as a path below the current one, so it can never carry a scheme, and xss checks it like any other address.

* Trim a relative README address before it gets the ./ prefix

xss trims the spaces written around an attribute value, but not encoded ones. A relative path such as `&#32;images/logo.png` therefore got the space inside the path after the ./ prefix, and the image stayed broken. The value is now decoded and trimmed first, as the browser trims the address.

* Decode a relative README address once and keep data links first

The relative address was decoded before `safeAttrValue` decoded it again, so `&#38;#105;mages` was checked and shown as `images` while the browser reads `&#105;mages`. Like `srcset`, it is now decoded once with `&amp;` for query strings, dropped when entities are left over, and written back escaped, so the browser reads the path that was checked.

Links to data URLs allowed with `allowDataUrl` are handled before relative addresses again, so they are no longer turned into paths.

* Escape a kept relative README address only once

`safeAttrValue` returns the value escaped already, so escaping its result again turned a quote in a path into `&amp;quot;`, and the browser requested another file. It is now only the check, and the decoded path is escaped once, like `srcset`.

The comments of the change are cut down to the one above the new function, as the code around has few.

* Drop relative README addresses with a scheme and reuse URL_ATTRIBUTES

A relative path never has a scheme, so an address xss empties that still has one after decoding (HTTPS://..., j:alert(1) from &#0000106avascript:) or is empty is dropped instead of being kept below ./, where it would request a path in the repository that does not exist. The worker imports URL_ATTRIBUTES from rewrite-html-urls, so the attributes it keeps and the ones the rewrite touches cannot drift apart.
2026-11-03 09:55:44 +00:00
Petar Petrov 2a69518dba Add option to combine energy sources of the same type (#55548)
* Add option to combine energy sources of the same type

Add a "Combine sources" toggle to the energy period selector menu and a
combine_sources option to the energy cards that list sources separately.
The Summary view always combines sources.

* Make combine_sources a per-card option with each card's current default

Cards render from their own combine_sources option instead of a flag on the
energy data. The power sources graph and the energy and power Sankeys default
to combined and gain a separate mode. The energy dashboard toggle now sets the
option on its cards through the strategies.

Keep stacked series and Sankey columns in the same order when switching.

* Keep usage graph series stable across modes instead of in ha-chart-base

Combined mode puts the sum on the first source's series and keeps the other
sources and the fallback series as empty series, so the series ids stay the
same and ECharts keeps the stack order. Revert the ha-chart-base guard.

* Only offer Combine sources on the energy panel

* Keep the legend entry of a source with only comparison data

* Keep the combine_sources setting on the energy collection

The collection holds the setting, unset by default and saved per collection
only when chosen, and re-publishes the data with it. Cards fall back to it
before their own default. A date picker shows the toggle only with
show_combine_sources, which the Electricity, Gas and Water views enable along
with an initial off value. The setting only applies while a source type is
configured twice.

* Only combine or split source types with two or more sources

A type with a single source keeps each card's current look in both modes.
The Electricity, Gas and Water views only show the toggle when their own
source types have duplicates.

* Keep the solar graph's forecasts from older requests or missing data
2026-11-03 11:48:14 +02:00
Aidan Timson 960bf08c84 Lock workflow actions with an actions.lock file (universe/changelog) (#55446)
* Lock workflow actions with an actions.lock file

* Run the actions lockfile check on every pull request

* Regenerate the actions lockfile after the rebase

* Pin Renovate's gh-actions-lock to the version CI verifies with
2026-11-03 10:17:08 +01:00
Aidan TimsonandClaude Fable 5.1 6c2bc65f07 Leave out README sections marked for the Marketplace to skip (#55571)
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-03 08:37:04 +00:00
Simon LamonandPetar Petrov e52547e374 dialog-helper-detail migrate list (#55466)
Co-authored-by: Petar Petrov <[email protected]>
2026-11-03 09:34:53 +01:00
WendelinandClaude Opus 5.5 7474e3e342 Reorder dashboard views by dragging their tabs (#55565)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-03 09:31:05 +01:00
puddly 8dbd1527d9 Cluster ZHA devices and adjust colors/icons (#55418)
* Add virtual parents for offline, parentless, and green power devices

* Tighter pull for virtual groups

* Merge offline with no parent

* Add hidden nodes to better space disconnected clusters

* Ignore neighbor table contributions from offline devices

* Make offline device edges 1.5x weaker

* Differentiate router color from end device color

* Switch `no parent` to `unknown parent`

* Adjust sizing

* Shrink the root symbol on mobile

* Fix tests
2026-11-03 10:26:48 +02:00
karwostsandCopilot Autofix powered by AI d9d849c843 Add segments to gauge UI (and single color select) (#55575)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 09:20:37 +01:00
Alexander Hass 4f73b43912 Resolve external statistics in the History panel (#55521)
* Resolve external statistics in the History panel

The History panel plots external statistics (`<domain>:<object_id>`), for example when the statistics graph card links to it, but treated them as entities everywhere else.

- The sources picker looks up an external statistic in the statistics metadata and shows its name instead of "Entity not found".
- Statistics without an entity take name and unit from their metadata, so the legend and tooltip no longer show the prettified ID without a unit.
- External statistic IDs are no longer sent to `history/stream`. Core rejects the whole subscription for an ID that is not an entity ID, which dropped the state history of every other picked entity.

* Use statistic metadata only for external statistics

The statistics of a removed entity got the unit from their metadata, but no device class. Its recent state history has the same unit and a device class, so both ended up in separate charts and the overlap was no longer trimmed. Other statistics keep a blank unit again and merge into the history line of their entity. The metadata is only fetched for external statistics.

* Reset the zoom when only external statistics remain

Removing the last entity from a zoomed view returned before the zoom was reset. The chart was rebuilt at full range, but the zoom slider kept the previous range.

* Reset the chart zoom when only external statistics are shown

Charts of external statistics stay mounted when the date range changes, because there is no state history to load. Resetting only the zoom range left the chart zoomed while the reset button was disabled. The zoom is now reset on the charts as well, and the normal path is unchanged again.

* Keep the statistics when the metadata lookup fails

A failed metadata lookup rejected the whole request, so the fetched statistics were dropped and the panel could stay on the loading spinner. External statistics are now shown under their ID instead.

* End the loading when the statistics request fails

With only external statistics selected there is no state history, so a failed statistics request left the panel on the loading spinner. A failed request now sets an empty result, guarded by the fetch ID so that an older request cannot overwrite newer data.

* Show external statistics like the statistic picker and inline the stream filter

The sources picker row of an external statistic gets the chart line icon and the source integration as context, as in ha-statistic-picker, instead of the default entity icon. The filter for the history stream has a single caller, so it is inlined in the History panel and its export and test are dropped. The comment on the blank unit of other statistics moves to the isExternalStatistic check it describes.

* Clear the previous statistics before fetching new ones in the History panel

Switching from one external statistic to another kept the chart of the previous one on screen and in the download until the new request was done, as no state history shows the loading then. The statistics are now cleared before the request, so the panel shows its loading state instead.
2026-11-03 10:14:26 +02:00
a6fb28dfef Wrap long descriptions in the entity settings (#55590)
* Wrap supporting text of entity settings rows

Long explanations such as the visibility description were truncated
with an ellipsis, especially in languages with longer translations.

Fixes #55584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGRG4gEJgUauHeEZcB

* Update supporting-text styles for row and list items

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 09:07:52 +01:00
renovate[bot] d4ad00c509 Update dependency tslib to v2.9.1 (#55588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-03 08:17:06 +02:00
renovate[bot] 2fdef6883c Update dependency @formatjs/intl-locale to v5.4.1 (#55582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 20:39:52 +01:00
Petar Petrov 598034b1d3 Keep chart tooltips inside the window (#55569)
* Keep chart tooltips inside the window

ECharts only fits tooltips into the chart, so a tooltip taller than the
chart opened above it and ran off the top of the window. Charts without
their own tooltip position now move it into the window, below the
cursor first.

The energy devices detail tooltip also lists the 10 largest devices,
largest first, and folds the rest into one "N other devices" row.

* Try both sides of the cursor and keep confined tooltips in the chart

When the window fit has to move a tooltip, try the other side of the
cursor horizontally too before clamping, so it does not cover the
hovered point. A confined tooltip now only moves within the visible part
of the chart and keeps its chart-confined spot otherwise, so it is not
clipped by containers that hide overflow.

* Keep confined tooltips on screen and skip hidden rows when folding

When the visible part of a confined chart is too small for the tooltip,
move the tooltip only as far as the window needs instead of leaving it
off screen.

Devices whose value the tooltip shows as 0 are no longer folded into the
"other devices" row, so that row never appears without the larger
devices it refers to.
2026-11-02 20:36:36 +01:00
Aidan Timson 4ad7c7a96a Add a level filter to the logs page (#55403)
* Add a level filter to the logs page

* Pass the logs level filter through My links

* Share the supported log levels and the log entry filtering

* Expand one logs filter at a time and count from the other filters
2026-11-02 20:34:57 +02:00
Petar Petrov 14f66ca105 Drop detected location details after choosing another place in onboarding (#55546)
* Drop detected location details after choosing another place in onboarding

* Reset the elevation when searching an address in onboarding

* Keep the chosen country when the browser time zone is unknown in onboarding
2026-11-02 17:01:27 +01:00
Aidan Timson 831dbeb975 Keep the system log skeleton left to right like the list (#55572) 2026-11-02 16:58:32 +01:00
Aidan Timson aecf17aae4 Show log level counts in the logs integration filter (#55402)
* Add optional count badges to the integration filter

* Show log level counts in the logs integration filter

* Label log count badges and hide them in the raw view

* Show log count badges on the integration filter's supporting line
2026-11-02 16:57:40 +01:00
renovate[bot] 037d3dad01 Update dependency @lezer/common to v1.5.5 (#55573) 2026-11-02 16:55:38 +01:00
WendelinandClaude Opus 5.5 e80c2e55b5 Migrate the remaining static lists to ha-list-base (#55560)
* Migrate the remaining static lists from ha-list

Moves the auth provider picker, the config flow menu step, the
onboarding address search results, the input select options, the
system log card, the Wi-Fi access points, the network storage mounts
and the adjust sum statistics list from the mwc-based ha-list and
ha-list-item to ha-list-base, ha-list-selectable, ha-list-item-button,
ha-list-item-option and ha-list-item-base.

- Make the onboarding address results and the Wi-Fi access points
  single-select lists, as they were activatable lists before
- Copy the picked access point into a new Wi-Fi configuration instead
  of using the access point object itself, which the form then changed
- Let the config flow menu descriptions wrap, as multiline-secondary did
- Keep the list padding, icon colors and mount icon column of the
  previous lists
- Remove unused element imports in the touched files

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add aria-label attributes to ha-list-base components for improved accessibility

* Refactor onboarding location component to use ha-list and ha-list-item for improved structure and interactivity

* Add reload label to network mounts and update translations for storage

* Refactor storage panel to use ha-list-item-base and ha-list-item-button for improved UI consistency

---------

Co-authored-by: Claude <[email protected]>
2026-11-02 17:14:24 +02:00
renovate[bot] 581b6abdd8 Update dependency eslint to v10.14.0 (#55568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 17:12:24 +02:00
renovate[bot] 4c43c7446a Update formatjs (#55566) 2026-11-02 15:51:56 +01:00
0f73094afd Add embeddable dashboard card components to the demo (#54348)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 15:50:19 +01:00
Maarten Lakerveld 53f8e6c8cb Add an accessible name to the to-do panel list pane (#55564)
Give the to-do panel list pane an accessible name

The migrated ha-list-base pane had no label, so screen readers announced an unnamed list. Reuse the existing "To-do lists" panel title, matching how the calendar panel labels its pane list.
2026-11-02 15:49:22 +01:00
Matthias de BaatandClaude Opus 5.5 5c9fc8723c Make content width and gutter consistent with tokens (#55270)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:33:47 +01:00
8995004feb ha-panel-todo migrate list (#55465)
* ha-panel-todo migrate list

* Update src/panels/todo/ha-panel-todo.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/todo/ha-panel-todo.ts

Co-authored-by: Aidan Timson <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 14:31:41 +01:00
4fa5991889 Refactor bulk delete labels (#55555)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-02 14:28:07 +01:00
Bram KragtenandClaude Opus 5.5 9925c10d03 Escape data URL links in markdown (#55558)
The data URL exception for links wrote the href back unescaped, so a quote
in the value could add attributes. It also added `download` to the cached
whitelist, which then allowed it in all later markdown.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:04:47 +02:00
Aidan Timson 7fc68028ea Use generic picker for install another version (#55552) 2026-11-02 13:46:54 +01:00
Paul Bottein 6136ec4909 Show the area first in integration device rows (#55557) 2026-11-02 13:23:06 +01:00
Josef Zweck d8a366b3d4 Add access risk to the Marketplace warning (#55559) 2026-11-02 13:08:54 +01:00
Paulus SchoutsenandClaude Opus 5.5 03e5814a57 Link the integration page to the integration's settings panel (#55514)
* Link the integration page to the integration's settings panel

The Connectivity settings panels, like Modbus and Infrared, are not
reachable from the integration page unless the integration has a config
entry. A user that opens the integration page looks for these settings.

The integration page now shows a button that opens the panel of the
integration when the integration has no entries. With entries, each
entry row already links to the panel. The button uses the panel name
from the Connectivity page. LoRaWAN, Modbus, Radio frequency, and USB
(Serial) are added to the map of integrations with a panel.

An integration with a panel and no config flow is set up in its panel.
For such an integration, the button is the primary action, and the page
does not show the YAML texts, the empty entries section, or the
"Add entry" button.

The demo Infrared and Radio frequency manifests now match core, so
these pages show it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EzDS5Gc7EVw1NsLpEB7yza

* Always show the settings panel button

An entry row links to the panel only while the entry is loaded. When
all entries are disabled, not loaded, or still setting up, the page had
no link to the panel. The integration page now always shows the
settings panel button.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EzDS5Gc7EVw1NsLpEB7yza

---------

Co-authored-by: Claude <[email protected]>
2026-11-02 11:40:28 +01:00
Vu Linh TruongandClaude Opus 5.5 9421cda8cf Use the dark brand icon in state badges when dark mode is on (#55493)
* Use the dark brand icon in state badges when dark mode is on

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Only follow dark mode from the UI context in state badges

Subscribing to the whole UI context made every state badge run an update
whenever an unrelated value such as the panel URL or the docked sidebar
changed. Consume only `themes.darkMode` through `@transform`, like
ha-media-browser-thumbnail does, so badges update only when dark mode
itself changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 11:40:23 +01:00
Vu Linh TruongandClaude Opus 5.5 25490642f9 Wrap media player row controls instead of overflowing narrow cards (#55492)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 11:40:17 +01:00
Norbert Rittel b01892a128 Entity ID format: Clarify usage for both new and restored IDs (#55495)
* Change "new entity" to "new ID"

* Fix grammar for "its settings"
2026-11-02 11:40:12 +01:00
JonathanandXyolyp 7baeed0cf6 Fix the lit-analyzer error for the ha-button mock in the flow dialog test (#55432)
Co-authored-by: Xyolyp <[email protected]>
2026-11-02 11:40:08 +01:00
renovate[bot] f1a9e2f69a Update dependency @versatiles/style to v7.1.0 (#55554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 11:40:04 +01:00
Aidan Timson fdfc00f3d7 Add loading state guidance to the components skill (#55455)
* Add loading state guidance to the components skill

* Describe spinner delays as context-specific
2026-11-02 10:35:46 +00:00
WendelinandClaude Opus 5.5 ee33e4bfde Fix picker rows missing the first click after scrolling (#55556)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-02 11:32:34 +01:00
Aidan Timson 39754807cc Show skeletons while the system log and older log lines load (#55456) 2026-11-02 11:31:34 +01:00
Jump-333 752251b1b6 Severity and segments to the tile card bar gauge (#55507) 2026-11-02 11:25:21 +01:00
Sridhar NetiandClaude Opus 5.5 95c613b098 Hide light brightness handle when brightness is unknown (#55277)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 10:13:01 +00:00
Paul Bottein a970c5c7f6 Limit the more info activity to the 100 most recent entries (#55553) 2026-11-02 11:10:45 +01:00
Simon Lamon 78f6a55e3c ha-scene-editor migrate list (#55478) 2026-11-02 09:58:20 +00:00
Simon Lamon 563b90c773 dialog-data-table-settings migrate list (#55464)
* dialog-data-table-settings migrate list

* Spacing
2026-11-02 09:58:16 +00:00
Paulus SchoutsenandClaude bf996a2521 Link the MCP integration pages to the AI settings (#55515)
The AI settings show how to connect to the MCP server and the URL of
each of its APIs. The MCP client and MCP server integration pages now
have an "AI settings" button that opens the MCP card on that page. The
button is the primary action when the page has no "Add entry" button.

The AI settings page scrolls to the section named in the URL hash, like
the backup settings page, and clears the hash after. In the demo, the
hash is the route of the page, so the button links to the page without
a section and the page keeps a hash that names no section.


Claude-Session: https://claude.ai/code/session_01EzDS5Gc7EVw1NsLpEB7yza

Co-authored-by: Claude <[email protected]>
2026-11-02 10:18:26 +01:00
Aidan Timson f04ce740a1 Shorten the Marketplace description on the settings page (#55549) 2026-11-02 10:05:18 +01:00
Petar Petrov 4a455cd883 Use the detected time zone in onboarding (#55545) 2026-11-02 09:19:44 +01:00
jimjaeger 6ae0d2ac72 Fix onboarding using US customary units when the location is detected (#55535)
Keep the detected unit system in onboarding
2026-11-02 09:13:22 +02:00
Joost LekkerkerkerandClaude 4b156e5666 Stretch chip container to fill host so outline matches host shape (#55510)
Co-authored-by: Claude <[email protected]>
2026-11-02 08:12:36 +01:00
Erwin DounaandAidan Timson ede6d92ab6 Show a skeleton while the live log loads (#55443)
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 08:09:59 +01:00
Thomas Mueller 5a4e664597 Seek on slider change in the media control card (#55542)
* Seek on slider change in the media control card

A touch drag ends without a click, so the card never seeked after a
drag. Use the slider's change event, which fires when a drag or tap
ends and on arrow keys.

* Remove media control card rendering test

The frontend testing guidance asks for tests of code that computes
something, not rendering tests.
2026-11-02 06:05:25 +00:00
renovate[bot] 40d2739a5f Update actions/setup-python action to v7.1.0 (#55544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 07:05:22 +01:00
renovate[bot] 52f632e4c2 Update pypa/gh-action-pypi-publish action to v1.14.3 (#55543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 07:05:04 +01:00
renovate[bot] 5ac2316f79 Update babel monorepo to v8.0.10 (#55531)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 07:03:47 +01:00
Thomas Mueller 22ff94a5d6 Fix media progress slider on Chromium < 125 (#55539)
matches(":state(dragging)") throws before Chromium 125. Use
customStates.has("dragging") instead.
2026-11-02 06:02:28 +00:00
karwosts 7d2fa3cf5c Fix date-only attribute formatting to not shift into other days (#55536) 2026-11-02 07:00:05 +01:00
renovate[bot] b8f0213d16 Update github/codeql-action action to v4.39.1 (#55541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 06:54:22 +01:00
renovate[bot] d4e0da9e21 Update home-assistant/actions digest to 86d4564 (#55540)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 06:54:06 +01:00
renovate[bot] 95c75bdd5b Update dependency @rspack/core to v2.3.0 (#55524)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 16:07:57 +01:00
renovate[bot] 36d57f1bba Update dependency hls.js to v1.7.4 (#55517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 08:19:51 +02:00
renovate[bot] 95d4e7705f Update dependency @vvo/tzdb to v6.202.0 (#55518)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 08:19:43 +02:00
Paulus SchoutsenandClaude Opus 5.5 87babb1a3b Match installed apps card spacing to the integrations dashboard (#55497)
* Align installed apps cards with the search field

The installed apps grid got an 8px margin from the shared apps style
on top of its own 16px padding. The cards sat 24px from the screen
edge while the search field sits at 16px. Reset the margin so the
cards line up with the search field and are wider on mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016677RTobVvbXRTMLYj3N4m

* Limit apps card margin change to mobile screens

Remove the side margin of the installed apps grid only below 600px.
Wider screens keep the 24px edge spacing, which matches the
integrations page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016677RTobVvbXRTMLYj3N4m

* Match installed apps grid spacing to the integrations dashboard

Use the same container spacing as the integrations dashboard: 16px
from the side edges and 24px below the toolbar on all screen sizes.
The margin from the shared apps style is reset, so it no longer adds
to the padding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016677RTobVvbXRTMLYj3N4m

---------

Co-authored-by: Claude <[email protected]>
2026-10-31 09:51:11 +01:00
Ruslan Sayfutdinov 2966588338 Fit the backup progress labels on narrow screens (#55502)
* Fit the backup progress labels on narrow screens

* Render the backup stage label only when there is a stage
2026-10-31 09:48:58 +01:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 7e07d3b5c4 Fix network graph button spacing (#55501)
* Fix zoom reset button spacing

* Replace margin-right with margin-inline-end

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Align with chart-base

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-31 09:47:52 +01:00
Ruslan Sayfutdinov 1b9d866977 Wrap long lines in the backup summary card (#55500) 2026-10-31 09:47:15 +01:00
Ruslan Sayfutdinov 0fc4a042c5 Restore bottom padding of the backup summary card (#55498) 2026-10-31 09:46:52 +01:00
renovate[bot] b865ca9357 Update dependency glob to v14.0.3 (#55505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-31 09:21:34 +01:00
renovate[bot] f817d76b73 Update dependency glob to v14 (#55503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-31 08:24:21 +02:00
renovate[bot] c695f577ff Update pnpm to v12.16.1 (#55496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 20:14:52 +02:00
Chanelle 4cb82bf40b Announce the number of search results in pickers (#55324)
* Announce the number of search results in pickers

Screen readers stay on the search field while the list filters, so typing in a picker or the quick search said nothing. A hidden status message now gives the number of matching items, or the not found label when nothing matches.

* Test the picker search results announcement

The status message is never seen, so a regression would go unnoticed. The test checks the count, the not found label, the cleared search, and that section titles are not counted.

* Count only matches and name the search in the picker announcement

The custom value row and the additional items, like the add new area row, were counted as results, so a search that matched nothing said there were results. They are no longer counted. A live region is only read out when its text changes, so a new search with the same count, or a second search that found nothing, said nothing. The message now names the search.

* Add the picker announcement messages to the gallery demo

The picker gallery's localizer only knew the older keys and returned the key for anything else, so searching a demo announced "ui.components.combo-box.result_count" instead of the count and the search. It now has both new messages, copied from en.json, and formats them with their values, so the demos announce "1 result for ‘desk’", "2 results for ‘kitchen’" and "No results for ‘zzz’".

* Move the search results message into a helper and test it there

The frontend testing guidance asks for no rendering tests, and the picker test mounted the component and checked its status text. The counting and the message now live in searchResultsStatus, a plain function the picker calls, with a unit test in its place: rows that are not results (section titles, placeholders, rows like "Add custom item") are not counted, a not found label that takes the search is used, and the message names the search so a new search is read out. The announcements are unchanged.

* Wait for typing to pause before announcing, and don't count Ask Assist

The picker announcement changed on every key press, so a screen reader started a new announcement for each character, over its own echo of the typed characters. The message now updates 500 ms after the rows stop changing, using the existing debounce helper, and is no longer recounted on every render.

The quick bar adds an "Ask Assist" row to every search through getItems, so it was counted: searching for something that matched nothing announced "1 result". The picker takes a new isSearchResult callback for rows like this, and the quick bar uses it to leave the assist row out. It isn't skipped in _isOption, because that would also stop the row being selectable. The gallery page documents both.

* Clear the picker announcement as soon as the rows change

With the pause, the last message stayed in the status region for half a second after the rows had changed, so it could describe the previous search. Going back to a search that had already been announced also wrote the same text again, so nothing was read out. The message is now cleared as soon as the rows change, at once when the search is emptied, and when the picker is removed. Clearing a status region isn't read out, and the next message is a change, so it is.
2026-10-30 12:58:47 +02:00
Daan Vervacke 449e7f8501 Wrap long title in YAML-only integration dialog (#55481) 2026-10-30 09:22:40 +01:00
Paul Bottein 7b0d350fb7 Add translation for the Marketplace panel title (#55486) 2026-10-30 08:19:14 +01:00
Maarten Lakerveld ed2cc613e1 Move the panel background stacking context off partial-panel-resolver (#55485)
Since #54801 the resolver creates a stacking context (isolation: isolate) so
its fixed ::before background sits behind the panel. The resolver is rendered
conditionally, once the startup data is available, and isolating it can
intermittently leave the Android WebView accessibility tree (Chromium
109-124) with an empty panel subtree while the page paints normally. UI
automation then never sees the dashboard; the companion app nightly E2E has
hit this on the legacy build (API 32/33/35) since the change landed.

Move the isolation to home-assistant-main's host, which exists before the
resolver is rendered. The pseudo-element and the Lovelace view background
both stay at z-index -1 in that context, in the same order as before, so the
themed background behaves exactly as #54801 intended. Measured with Maestro
on a Chrome 109 emulator: 0 stale trees in 62 cold loads, against 11 in 100
with the isolation on the resolver.
2026-10-30 08:18:36 +02:00
renovate[bot] 79f193f0d8 Update pnpm to v12.16.0 (#55490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:44 +02:00
renovate[bot] 149f24255b Update dependency typescript-eslint to v8.72.1 (#55483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:37 +02:00
renovate[bot] c1a82bfe7f Update dependency browserslist to v4.29.5 (#55482)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:29 +02:00
renovate[bot] 5083578c1e Update Node.js to v24.22.2 (#55489)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 08:14:21 +02:00
Paul Bottein 5f11dc4580 Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-29 17:12:40 +01:00
Simon Lamon 27489f188e date-range-picker migrate list (#55471) 2026-10-29 17:57:24 +02:00
Simon Lamon 97fffc3b43 ha-config-person migrate list (#55473) 2026-10-29 17:56:23 +02:00
renovate[bot] a30193b8ef Update dependency marked to v18.1.2 (#55470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 17:50:42 +02:00
Aidan Timson cf063dc19f Update the PR template for the contributors docs (#55459) 2026-10-29 17:05:36 +02:00
Simon Lamon 5db829b7be pick-auth-provider migrate list (#55462) 2026-10-29 17:04:51 +02:00
Maarten Lakerveld 421f8b1986 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 17:02:23 +02:00
Simon Lamon a140eedc6e Clean up ha-radio-list-item (#55454) 2026-10-29 17:00:59 +02:00
renovate[bot] 2933568658 Update dependency home-assistant-js-websocket to v9.8.1 (#55450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 16:59:26 +02:00
Simon Lamon 29faeaf7fa Remove incorrect type usage from date-range-picker (#55460) 2026-10-29 16:58:55 +02:00
Simon Lamon 95ba532ceb Removes incorrect type from cast (#55461) 2026-10-29 16:58:33 +02:00
Guillaume Rischard 352ecb9d6d Simplify OpenStreetMap attribution (#55447)
As per https://osmfoundation.org/wiki/Licence/Attribution_Guidelines:

> Attribution must be to “OpenStreetMap”.
[...]
> The historical forms of attribution “© OpenStreetMap contributors” or “© OpenStreetMap” are acceptable.
2026-10-29 15:27:49 +01:00
Erwin Douna 3f73049a3d Fade out energy period loading spinner (#55431) 2026-10-29 15:06:53 +01:00
Petar Petrov 50ad0c6cd0 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 14:58:56 +01:00
Paul Bottein 78f95bbb06 Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 14:36:46 +01:00
WendelinandClaude Opus 5.5 6134056776 Migrate the device, area and integration lists to ha-list-base (#55445)
* Migrate the config device, area and integration lists from ha-list

Moves the device child, via and linked devices cards, the device
entities card, the area page, the Matter fabrics and ping dialogs, the
Thread border routers, the Z-Wave remove node dialog and the Z-Wave
config entry picker from the mwc-based ha-list and ha-list-item to
ha-list-base, ha-list-nav, ha-list-item-button and ha-list-item-base.

- Render links as ha-list-item-button with an href instead of an anchor
  around a list item, so the rows take part in keyboard navigation
- Render automations and scenes that are not editable as
  ha-list-item-base with the existing tooltip, as on the device page
- Wrap the area scripts and the Thread border routers in a list, as
  the other sections already were
- Only render the remove fabric button where mwc showed it, and give it
  a label
- Keep the list padding and row alignment of the previous lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add aria labels and adjust padding for ha-list components in device and integration dialogs

* Refactor aria labels for related lists in ha-config-area-page and update translations

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 14:09:58 +01:00
Aidan Timson b79c6350ec Add date format presets to the clock and date cards (#55434) 2026-10-29 11:15:42 +00:00
Paul Bottein 8148041ddc Show multi-select chips in a field with an inline add button (#55416) 2026-10-29 09:59:54 +00:00
WendelinandClaude Opus 5.5 4265624c0e Wrap long text in debug tool and network discovery rows (#55438)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-29 09:40:29 +00:00
Paul Bottein ed4179de8f Use the state color by default in alert cards and badges (#55436) 2026-10-29 09:29:06 +00:00
WendelinandClaude Opus 5.5 e407a1b2dd Migrate the remaining selection lists to ha-list-selectable (#55437)
* Migrate the remaining selection lists from ha-list to ha-list-selectable

Moves the media management dialog, the calendar panel pane, the expose
entities dialog, the user visibility condition and the select view dialog
from the mwc-based ha-list, ha-check-list-item and ha-radio-list-item to
ha-list-selectable and ha-list-item-option. The read-only ZHA endpoint
list now uses ha-list-base, as its rows already were ha-list-item-base.

- Use ha-list-selectable-virtualized for the expose entities dialog,
  replacing the custom click and keyboard handling around lit-virtualizer
- Render the calendar pane as its own list instead of reusing the
  dropdown items, sharing the selection logic with the dropdown
- Make the select view dialog a single-select list and keep the icon
  column aligned for views without an icon
- Drop the media management workaround for spurious mwc select events
- Render the create calendar row as ha-list-item-button
- Remove unused element imports in the touched files

After this, ha-check-list-item is only used by the todo list card and
ha-radio-list-item is unused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Refactor ha-list components for improved focusability handling and update list items logic

* Enhance focus handling in ha-list-base and update tests for disabled items

* use helper

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 11:16:09 +02:00
Aidan Timson e851101a93 Fix the Marketplace search field overlapping the top bar icons on narrow screens (#55439)
Let the narrow data table search field shrink to fit
2026-10-29 11:13:19 +02:00
Tristan Cartledge d43e1ef4cc Fix duplicated hidden areas when reordering areas in the areas dashboard editor (#55424) 2026-10-29 09:37:33 +01:00
Petar Petrov f699019994 Ignore YAML dashboard update event on first load (#55430) 2026-10-29 09:28:05 +01:00
Daan Vervacke fe90b0339d Show blueprint usage count as text on narrow screens (#55427)
* Show blueprint usage count as text on narrow screens

* Show the blueprint usage count before the path on narrow screens

* Name the blueprint usage column and skip it for broken blueprints
2026-10-29 08:34:42 +02:00
renovate[bot] 3b5ba802f6 Update dependency @rspack/dev-server to v2.2.2 (#55429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:30:43 +02:00
JonathanandXyolyp b0c6e7f564 Preselect the only allowed unit also in an optional unit selector (#55417)
Co-authored-by: Xyolyp <[email protected]>
2026-10-29 08:17:31 +02:00
renovate[bot] 658a23724e Update tsparticles to v4.5.0 (#55428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:00:52 +02:00
Maarten Lakerveld f859183628 Name maintenance problem tiles after the device and the entity (#55420)
Problem binary sensors were added to the maintenance view using the
battery tile builder, which names every tile after its device. A device
with several problem sensors (overheating, overvoltage, ...) then showed
identical tiles. Problem tiles now use a device plus entity name; the
entity part is dropped automatically when the sensor has no name of its
own, so single-sensor devices still read as just the device name.
Battery tiles keep the device-only name.

Fixes home-assistant/frontend#55413
2026-10-28 20:42:19 +01:00
Paul Bottein 53dc14f413 Keep the section row divider under the entities card header (#55415) 2026-10-28 19:42:41 +01:00
Aidan Timson f2523095ff Remember logs page search, integrations and raw view (#55399)
* Remember logs page search, integrations and raw view

* Keep stored logs choices from links and loading apps

* Keep logs choices when closing dialogs and while apps load

* Restore stored logs choices when linked to the page again
2026-10-28 16:03:00 +00:00
ef5c04b67f Show stop button in the media player more-info dialog (#54794)
* Don't hide stop action on playing media players

* Show stop as a secondary button when play/pause exists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add media player entity for "Playing The Music" and update media control card

* Implement logic to remove secondary stop control when play or pause is available in media player

* Test removing the secondary stop control

Runs real computeMediaControls output through removeSecondaryStopControl,
like its callers do, to protect when stop is removed and when it has to
stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-28 18:01:26 +02:00
WendelinandClaude Opus 5.5 10ed6f709e Migrate the data table filters to ha-list-selectable (#55412)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-28 16:55:17 +01:00
Bram KragtenandClaude Opus 5.5 c22de3bc7c Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 16:08:12 +01:00
443 changed files with 17281 additions and 5218 deletions

No files matched your search

+31 -1
View File
@@ -1,6 +1,6 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, loading states, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
@@ -91,6 +91,36 @@ html`
`;
```
## Loading States
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
- Don't fade in the loaded content itself.
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
```ts
html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
role="progressbar"
aria-label=${this._i18n.localize("ui.common.loading")}
>
<ha-skeleton-text></ha-skeleton-text>
</div>
</ha-fade-in>
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
`;
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
@@ -10,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
## Theme And Layout Basics
- Use Home Assistant CSS custom properties instead of hardcoded colors.
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
- Keep components mobile-first and enhance for desktop.
- Keep layouts RTL-safe. Prefer logical properties when they fit.
+4
View File
@@ -12,3 +12,7 @@
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
# on a conflict, restore it from the base branch and run `gh actions-lock`.
.github/workflows/actions.lock linguist-language=YAML -merge
+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+6
View File
@@ -0,0 +1,6 @@
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
# https://github.com/rhysd/actionlint/issues/711
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
@@ -16,6 +16,7 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
@@ -24,7 +25,7 @@ runs:
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
cache: false
@@ -35,6 +36,7 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
+1
View File
@@ -28,6 +28,7 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
+41
View File
@@ -0,0 +1,41 @@
name: Verify actions lockfile
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
name: Verify actions lockfile
runs-on: ubuntu-latest
env:
GH_ACTIONS_LOCK_VERSION: v0.4.1
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
- name: Verify lockfile
env:
GH_TOKEN: ${{ github.token }}
run: gh actions-lock --verify
+190
View File
@@ -0,0 +1,190 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/actionlint.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/actions-lock.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/cast_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/ci.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/codeql-analysis.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
'.github/workflows/copilot-setup-steps.yml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/demo_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/dependency-review.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/design_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/design_preview.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/e2e.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/labeler.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
'.github/workflows/lock.yaml':
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
'.github/workflows/nightly.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/pull-request-standards.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/relative-ci.yaml':
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
'.github/workflows/release-drafter.yaml':
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
'.github/workflows/release.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
'.github/workflows/restrict-task-creation.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/stale.yaml':
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
'.github/workflows/sync-device-classes.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/sync-sensor-entity-constants.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/translations.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
dependencies:
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
ref: 'v5.0.0'
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
owner_id: 44036562
repo_id: 476372928
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
ref: 'v9.0.0'
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
owner_id: 44036562
repo_id: 205262760
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
ref: 'v7.0.0'
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
owner_id: 44036562
repo_id: 177139928
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
ref: 'v7.1.0'
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
owner_id: 44036562
repo_id: 192625525
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
ref: 'v6.2.0'
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
owner_id: 44036562
repo_id: 192625525
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
ref: 'v11.0.0'
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
owner_id: 44036562
repo_id: 200437066
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
ref: 'v6.0.2'
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
owner_id: 7513068
repo_id: 113918130
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
ref: 'v4.39.1'
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
owner_id: 9919
repo_id: 259445878
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
ref: 'master'
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
owner_id: 13844975
repo_id: 173703505
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
ref: '2026.07.0'
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
owner_id: 13844975
repo_id: 184234812
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
ref: 'v8.1.1'
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
owner_id: 18365890
repo_id: 197176365
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
ref: 'v1.14.3'
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
owner_id: 647025
repo_id: 178055147
uses:
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
ref: 'v3.2.4'
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
owner_id: 43522883
repo_id: 398376354
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
ref: 'v7.10.0'
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
owner_id: 58292376
repo_id: 139022359
+8 -8
View File
@@ -18,7 +18,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Deploy Development
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -30,17 +30,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Cast
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Deploy Production
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Cast
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
uses: $/.github/actions/prepare-dependencies
lint:
name: Lint and check format
@@ -43,7 +43,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
@@ -89,7 +89,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
@@ -110,7 +110,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
@@ -127,7 +127,7 @@ jobs:
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:javascript-typescript"
+38 -3
View File
@@ -22,10 +22,45 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
# These steps mirror .github/actions/setup with node-modules-cache enabled.
# Copilot runs this workflow in a context that can't resolve `$/`
# self-repository actions, so the steps are inlined here.
- name: Restore complete dependency tree
id: dependency-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
node-modules-cache: true
path: |
node_modules
~/.local/share/pnpm/store
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
id: pnpm-store
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.pnpm-store.outputs.path
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
+8 -8
View File
@@ -19,7 +19,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Demo Development
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.unique_deploy_url }}
@@ -31,17 +31,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Demo
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Demo Production
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Demo
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -25,17 +25,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Gallery
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -29,17 +29,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Build Gallery
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
uses: ./.github/actions/netlify-deploy
uses: $/.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
+16 -16
View File
@@ -32,13 +32,13 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
uses: $/.github/actions/prepare-dependencies
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
defaults:
run:
@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
uses: $/.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -97,12 +97,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -128,12 +128,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: ./.github/actions/build
uses: $/.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001 --ipc=host
defaults:
run:
@@ -176,7 +176,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001 --ipc=host
defaults:
run:
@@ -230,7 +230,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001 --ipc=host
defaults:
run:
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -326,7 +326,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
node-modules-cache: true
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
@@ -124,7 +124,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
+5 -5
View File
@@ -31,15 +31,15 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
cache: false
@@ -85,7 +85,7 @@ jobs:
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
skip-existing: true
@@ -146,7 +146,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
with:
immutable: false
cache: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
uses: $/.github/actions/setup
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
+1 -1
View File
@@ -1 +1 @@
24.22.1
24.22.2
+1 -1
View File
@@ -40,7 +40,7 @@ Never run `tsc` or `pnpm lint:types` with file arguments. When `tsc` receives fi
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-components`: dialogs, forms, alerts, loading states, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
@@ -1,4 +1,3 @@
/* global module, require */
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
// tagged template literals using `minify-literals` (html-minifier-next +
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
// build (e.g. Shelly Wall Display) can't parse that, so any
// var(--divider-color) border falls back to currentColor. Always
// compile hex alpha colors to rgba() in legacy builds.
lightningcssInclude: browserslistEnv.startsWith("legacy")
? lightningcssModule.Features.HexAlphaColors
: 0,
};
})
);
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
include: lightningcssInclude,
},
})
)
+38
View File
@@ -68,8 +68,42 @@ const CONFIG_FILES = [
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
// Its exports define the components source aliases below
path.join(__dirname, "..", "packages", "components", "package.json"),
];
// Resolve @home-assistant/components entry points to their sources, so the package
// needs no build step during development. The entry points are read from the
// "ha-source" export condition of the package; subpath patterns are expanded
// for every element, i.e. every directory with a global.ts, like "card".
const COMPONENTS_PACKAGE_DIR = path.join(
paths.root_dir,
"packages",
"components"
);
const COMPONENTS_SRC_DIR = path.join(
COMPONENTS_PACKAGE_DIR,
"src",
"components"
);
const componentElements = fs
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
.filter((file) => path.basename(file) === "global.ts")
.map((file) => path.dirname(file).split(path.sep).join("/"));
const componentsSourceAliases = Object.fromEntries(
Object.entries(require("../packages/components/package.json").exports)
.filter(([, target]) => target["ha-source"])
.flatMap(([subpath, target]) =>
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
`${path.posix.join("@home-assistant/components", name ? subpath.replaceAll("*", name) : subpath)}$`,
path.join(
COMPONENTS_PACKAGE_DIR,
name ? target["ha-source"].replaceAll("*", name) : target["ha-source"]
),
])
)
);
// Patches change a package's files but not its version, which is all the
// node_modules snapshot checks, so their contents go into the version too.
const PATCHES_DIR = path.join(paths.root_dir, "patches");
@@ -244,6 +278,9 @@ const createRspackConfig = ({
path: paths.root_dir,
env: "legacy",
}),
// Keep rgba() instead of #rrggbbaa, see
// minify-template-literals-loader.cjs
include: { hexAlphaColors: true },
},
},
],
@@ -428,6 +465,7 @@ const createRspackConfig = ({
"src/resources/polyfills/bigdecimal.ts"
),
}),
...componentsSourceAliases,
"lit/static-html$": "lit/static-html.js",
"lit/decorators$": "lit/decorators.js",
"lit/directive$": "lit/directive.js",
+5 -5
View File
@@ -1,5 +1,5 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
Referrer-Policy: no-referrer-when-downgrade
@@ -8,13 +8,13 @@
X-XSS-Protection: 1; mode=block
/images/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/manifest.json
Cache-Control: public, max-age: 3600, s-maxage=3600
Cache-Control: public, max-age=3600, s-maxage=3600
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
+1 -1
View File
@@ -1,6 +1,6 @@
{
"background_color": "#FFFFFF",
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
"dir": "ltr",
"display": "standalone",
"icons": [
+2 -2
View File
@@ -5,7 +5,7 @@
<meta property="og:type" content="website" />
<meta
property="og:description"
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
/>
<meta
property="og:image"
@@ -16,7 +16,7 @@
<meta name="twitter:title" content="Home Assistant Cast" />
<meta
name="twitter:description"
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
/>
<meta
name="twitter:image"
+1 -1
View File
@@ -52,7 +52,7 @@
<p>
Home Assistant Cast allows you to show your Home Assistant data on a
Chromecast device and allows you to interact with Home Assistant on
Google Assistant devices with a screen.
Google Home devices with a screen.
</p>
</div>
+42 -50
View File
@@ -1,8 +1,7 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -14,12 +13,14 @@ import {
enableWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { atLeastVersion } from "../../../../src/common/config/version";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-selectable";
import {
getLegacyLovelaceCollection,
getLovelaceCollection,
@@ -53,6 +54,9 @@ class HcCast extends LitElement {
`
: undefined;
const views = this.lovelaceViews ?? [{ title: "Home" }];
const hasIcons = views.some((view) => view.icon);
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
@@ -93,45 +97,41 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
view.icon
? html`
<ha-icon
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item>
</ha-list-item-option>
`
)}</ha-list
>
)}
</ha-list-selectable>
`
}
@@ -194,14 +194,6 @@ class HcCast extends LitElement {
});
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
@@ -215,8 +207,13 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
// for the selected view, so the current view can still be cast again.
private async _handlePickView(
ev: HASSDomCurrentTargetEvent<HaListItemOption>
) {
const index = Number(ev.currentTarget.dataset.index);
const path = this.lovelaceViews?.[index]?.path ?? index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
}
@@ -271,16 +268,11 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
+1 -1
View File
@@ -48,7 +48,7 @@ const translateErr = (err) =>
const INTRO = html`
<p>
Home Assistant Cast allows you to cast your Home Assistant installation to
Chromecast video devices and to Google Assistant devices with a screen.
Chromecast video devices and to Google Home devices with a screen.
</p>
<p>
For more information, see the
+1 -1
View File
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
const media = loadRequestData.media;
// Special handling if it came from Google Assistant
// Special handling if it came from Google Home
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+1 -1
View File
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
{
type: "markdown",
title: "Home Assistant Cast",
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
touchSupported
? "\n\nYou are able to interact with this demo using the touch screen."
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
+1 -1
View File
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
// We received a play media command, hide Lovelace and show media player
showMediaPlayer();
const media = loadRequestData.media;
// Special handling if it came from Google Assistant
// Special handling if it came from Google Home
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+5 -5
View File
@@ -1,21 +1,21 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
/api/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/assets/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
+173
View File
@@ -0,0 +1,173 @@
import { ContextEvent } from "@lit/context";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import type { ScriptObject } from "./script-data";
import { readScriptObject } from "./script-data";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so an element inside another component still
// finds the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* The base of the embed elements that render dashboard content. The element
* uses the provider with the ID in `provider`, else the closest provider
* around it, else the first provider on the page. If the page has no
* provider, the element adds an empty one.
*/
export abstract class EmbedElement<
Config extends ScriptObject,
> extends LitElement {
/**
* The configuration of the content, the same as in a dashboard. Also read
* from a JSON or YAML script child, see `readScriptObject`.
*/
@property({ type: Object }) public config?: Config;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
protected _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// element and its content. Context requests only reach ancestors by
// themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const [origin] = ev.composedPath();
const node = origin instanceof HTMLElement ? origin : this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// The page's address belongs to the page, so only a hash on it, such as
// an anchor, navigates there. The browser does that itself, as the app's
// navigate() doesn't scroll to the anchor.
this.addEventListener("hass-navigate", (ev) => {
const { path, replace } = ev.detail;
ev.preventDefault();
if (!path.startsWith("#")) {
this.navigateTo(path, replace);
} else if (replace) {
window.location.replace(path);
} else {
window.location.hash = path;
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the element keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptObject(this);
if (data !== undefined) {
this.setScriptData(data);
}
}
/**
* A navigate action of the content, other than to a hash on the page. It
* leaves the page where it is, like actions that open a dialog. `replace`
* is the action's `navigation_replace`.
*/
protected navigateTo(_path: string, _replace?: boolean) {
// Nothing to navigate to, outside a dashboard
}
/** Whether the data of a script child is a configuration of the content. */
protected abstract isConfig(data: ScriptObject): data is Config;
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
protected setScriptData(data: ScriptObject) {
if (this.isConfig(data)) {
this.config = data;
}
}
/** The content, or `undefined` while there is nothing to render. */
protected abstract renderContent(
hass: HomeAssistant
): TemplateResult | undefined;
// The content in the theme of the provider
protected render() {
const hass = this._hass;
const content = hass && this.renderContent(hass);
if (!hass || !content) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
dir=${computeRTLDirection(hass)}
>
${content}
</div>`;
}
}
+3
View File
@@ -4,6 +4,9 @@ import "./workers";
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
+22 -141
View File
@@ -1,139 +1,44 @@
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
private _card = createRef<HuiCard>();
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card
${ref(this._card)}
.hass=${this._hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>
</div>`;
return html`<hui-card
${ref(this._card)}
.hass=${hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>`;
}
protected updated() {
@@ -163,7 +68,7 @@ export class HaEmbedCard extends LitElement {
};
static styles = [
embedThemeStyles,
embedElementStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
@@ -176,9 +81,6 @@ export class HaEmbedCard extends LitElement {
:host([full-width]) {
grid-column: 1 / -1;
}
:host([hidden]) {
display: none;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
@@ -186,27 +88,6 @@ export class HaEmbedCard extends LitElement {
)
);
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
+173
View File
@@ -0,0 +1,173 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/hui-root";
import type { HomeAssistant } from "../../../src/types";
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
* view in `view`, so switching tabs and following links to its views don't
* change the page's URL. The dashboard scrolls inside the element, under its
* toolbar.
*/
@customElement("ha-embed-dashboard")
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
private _scroller = createRef<HTMLDivElement>();
// The route of hui-root, here without a page URL around it. A new
// configuration gives a new route, as hui-root only looks up the view of
// the path again when its route changes, and the views may have moved.
private _route = memoizeOne(
(view: string | undefined, _config: LovelaceConfig) => ({
prefix: "",
path: `/${view ?? ""}`,
})
);
protected get rawConfig() {
return this.config;
}
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
return isDashboardConfig(data);
}
// The view shown, and the views before it, which the back button of a
// subview without a `back_path` goes back through, as the browser's back
// would in the app
private _shownView?: string;
private _history: (string | undefined)[] = [];
// Going back, or a navigate action with `navigation_replace`, which
// replaces the view shown rather than adding to the views before it
private _skipHistory = false;
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this.view === this._shownView) {
return;
}
if (this._shownView !== undefined) {
if (!this._skipHistory) {
this._history.push(this._shownView);
}
this._scroller.value?.scrollTo({ top: 0 });
}
this._skipHistory = false;
this._shownView = this.view;
}
protected navigateTo(path: string, replace?: boolean) {
const view = this.viewOfPath(path);
if (view === undefined || this.isSameView(view, this.view)) {
return;
}
this._skipHistory = Boolean(replace);
this.view = view;
}
// The embedded attribute is in the template, so that hui-root has it when
// it connects and skips the listeners on the page
protected renderContent(hass: HomeAssistant) {
if (!this._lovelace?.config.views.length) {
return undefined;
}
return html`<div class="scroller" ${ref(this._scroller)}>
<hui-root
embedded
no-edit
.hass=${hass}
.lovelace=${this._lovelace}
.route=${this._route(this.view, this._lovelace.config)}
.narrow=${this._narrow.value ?? false}
@view-navigate=${this._viewNavigate}
></hui-root>
</div>`;
}
// A tab, or the back button of a subview, which goes to its `back_path`
// when that is one of the dashboard's views, else to the view before it
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
const view = this.viewOfPath(ev.detail.path);
if (!ev.detail.back) {
if (view !== undefined) {
this.view = view;
}
return;
}
const target = view === undefined ? this._previousView() : view;
// Following a `back_path` goes back too, so the view it goes to leaves
// the views before
if (
view !== undefined &&
this.isSameView(this._history[this._history.length - 1], view)
) {
this._history.pop();
}
if (!this.isSameView(target, this.view)) {
this._skipHistory = true;
this.view = target;
}
}
// The last view before that isn't the one shown, else the first view
private _previousView(): string | undefined {
while (this._history.length) {
const previous = this._history.pop();
if (!this.isSameView(previous, this.view)) {
return previous;
}
}
return undefined;
}
static styles = [
EmbedLovelaceElement.styles,
css`
/* Contained, so that the toolbar and background of hui-root, which are
fixed to the window in the app, are fixed to the element */
:host {
height: 600px;
contain: layout paint;
}
.theme,
.scroller {
height: 100%;
}
.scroller {
overflow: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-dashboard": HaEmbedDashboard;
}
}
+40 -12
View File
@@ -8,6 +8,7 @@ import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
@@ -22,6 +23,8 @@ import {
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
@@ -40,6 +43,7 @@ import { readScriptData } from "./script-data";
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
devices?: DemoDevice[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
@@ -54,8 +58,8 @@ const areaName = (areaId: string) => {
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
* Holds a mocked Home Assistant backend for all embed elements inside it.
* Elements in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@@ -67,13 +71,19 @@ export class HaEmbedProvider extends LitElement {
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `calendars` and `energy`.
* `areas`, `devices`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
@property({ attribute: false }) public areas?: DemoArea[];
/** All devices, which entities reference with their `device_id`. */
@property({ attribute: false }) public devices?: DemoDevice[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
@@ -155,7 +165,11 @@ export class HaEmbedProvider extends LitElement {
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
// The registry of the entities, without settings such as calendar colors
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, with the options each entity sets, such
// as timer presets. Other settings, such as calendar colors, are not set.
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
@@ -166,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
options:
this.entities.find((item) => item.entity_id === entity.entity_id)
?.options ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
@@ -222,6 +239,7 @@ export class HaEmbedProvider extends LitElement {
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.devices = data.devices;
this.calendars = data.calendars;
this.energy = data.energy;
}
@@ -238,14 +256,19 @@ export class HaEmbedProvider extends LitElement {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
if (
changedProps.has("entities") ||
changedProps.has("areas") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
@@ -253,11 +276,16 @@ export class HaEmbedProvider extends LitElement {
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
const devicesById: Record<string, DeviceRegistryEntry> = {};
devices.forEach((device) => {
devicesById[device.id] = toDeviceRegistryEntry(device);
});
// Before the entities, so that their names can include the area and
// device
this.hass.updateHass({ areas: areasById, devices: devicesById });
// Entities that services changed keep their state when only the areas
// change
// or devices change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
+95
View File
@@ -0,0 +1,95 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
import "../../../src/panels/lovelace/sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../../../src/panels/lovelace/sections/section-container";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a dashboard section, or a section strategy, with the backend of a
* provider, see `EmbedElement`. The section takes the width of the element.
*/
@customElement("ha-embed-section")
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
// A title becomes a heading card, as on a dashboard
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
// A section has cards, sections inside it, a strategy that makes them, a
// title, or the type of a custom section
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
return (
"cards" in data ||
"sections" in data ||
"strategy" in data ||
"title" in data ||
("type" in data && typeof data.type === "string")
);
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// The section that hid the element is gone, and the next one starts
// visible without telling it
if (changedProps.has("config") && !this.config) {
this.hidden = false;
}
}
// In the container that draws the background of the section, as in a
// sections view
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
const config = this._normalizedConfig(this.config);
return renderSectionContainer(
hass,
config,
html`<hui-section
.hass=${hass}
.config=${config}
@section-visibility-changed=${this._visibilityChanged}
></hui-section>`
);
}
private _visibilityChanged(
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
) {
// Sections inside the section fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this.hidden = !ev.detail.value;
}
static styles = [
embedElementStyles,
sectionContainerStyles,
css`
:host {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-section": HaEmbedSection;
}
}
+134
View File
@@ -0,0 +1,134 @@
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import "../../../src/panels/lovelace/views/hui-view";
import "../../../src/panels/lovelace/views/hui-view-background";
import "../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../src/types";
import {
EmbedLovelaceElement,
findViewIndex,
isDashboardConfig,
} from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard view, a view strategy, or one view of a dashboard or
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
* `config` holds the view.
*/
@customElement("ha-embed-view")
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
/**
* A dashboard configuration or dashboard strategy, to show its `view`
* instead of `config`. Also read from the `dashboard` key of a script child,
* next to `view`.
*/
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
private _viewDashboard = memoizeOne(
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
);
protected get rawConfig() {
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
}
// A view strategy and a dashboard strategy both have a `strategy` key, so
// a dashboard comes in its own key, which no view has
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
return !("dashboard" in data);
}
protected setScriptData(data: ScriptObject) {
if (!("dashboard" in data)) {
super.setScriptData(data);
return;
}
const { dashboard } = data;
if (
typeof dashboard === "object" &&
dashboard !== null &&
isDashboardConfig(dashboard)
) {
this.dashboard = dashboard;
}
if (
"view" in data &&
(typeof data.view === "string" || typeof data.view === "number")
) {
this.view ??= String(data.view);
}
}
// Nothing for a dashboard without views, such as an empty strategy result.
// The view's theme and background, or the dashboard's background, as in
// hui-root. Each view gets its own hui-view, as there, so that a view
// strategy still generating for the view before can't replace the cards of
// this one.
protected renderContent(hass: HomeAssistant) {
const views = this._lovelace?.config.views;
if (!this._lovelace || !views?.length) {
return undefined;
}
const index = findViewIndex(views, this.view);
const viewConfig = views[index];
const background =
viewConfig.background || this._lovelace.config.background;
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
<hui-view-background .hass=${hass} .background=${background}>
</hui-view-background>
${keyed(
viewConfig,
html`<hui-view
.hass=${hass}
.lovelace=${this._lovelace}
.index=${index}
.narrow=${this._narrow.value ?? false}
></hui-view>`
)}
</hui-view-container>`;
}
// A fixed background stays inside the element. With a height set on the
// element, a panel view fills it, and other views keep their own height.
static styles = [
EmbedLovelaceElement.styles,
css`
:host {
contain: layout paint;
}
.theme,
hui-view-container,
hui-view {
display: block;
height: 100%;
}
/* The background sits below the view, above the element's own */
hui-view-container {
position: relative;
isolation: isolate;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-view": HaEmbedView;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { property, state } from "lit/decorators";
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import {
checkStrategyShouldRegenerate,
generateLovelaceDashboardStrategy,
} from "../../../src/panels/lovelace/strategies/get-strategy";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
// The width below which the app is narrow, see home-assistant-main
const NARROW_WIDTH = 870;
/** The index of the view with this path or index, if there is one. */
const viewIndex = (
views: LovelaceViewRawConfig[],
view: string
): number | undefined => {
const byPath = views.findIndex((item) => item.path === view);
if (byPath !== -1) {
return byPath;
}
const index = Number(view);
return Number.isInteger(index) && index >= 0 && index < views.length
? index
: undefined;
};
/** Whether script data is a dashboard: its views, or a strategy for them. */
export const isDashboardConfig = (
data: ScriptObject
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
/** The index of the view with this path or index, else the first view. */
export const findViewIndex = (
views: LovelaceViewRawConfig[],
view?: string
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
/**
* The base of the embed elements that render a dashboard, or one of its
* views. It generates dashboard strategies, again when the registries they
* depend on change, and holds a dashboard that can't be edited or saved.
*/
export abstract class EmbedLovelaceElement<
Config extends ScriptObject,
> extends EmbedElement<Config> {
/** The path or index of the view to show. Without it, the first view. */
@property() public view?: string;
@state() protected _lovelace?: Lovelace;
// Narrow like the app at the same width, measured on the element because
// it rarely takes the width of the page
protected _narrow = new ResizeController(this, {
callback: (entries) =>
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
});
// The configuration of the dashboard in `_lovelace`, or being generated
private _rawConfig?: LovelaceRawConfig;
// Counts the generations started, so that only the latest one is shown.
// Generating again for changed registries keeps the same configuration.
private _generation = 0;
// The backend at the last update, to tell what a strategy depends on changed
private _previousHass?: HomeAssistant;
/** The dashboard to render. The same object until it changes. */
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
/**
* The view of the dashboard a path goes to, relative like `batteries` or
* with the dashboard like `/maintenance/batteries`, if it has that view.
*/
protected viewOfPath(path: string | undefined): string | undefined {
const views = this._lovelace?.config.views;
const view = path
? decodeURI(new URL(path, "http://embed/").pathname)
.split("/")
.filter(Boolean)
.pop()
: undefined;
return views && view !== undefined && viewIndex(views, view) !== undefined
? view
: undefined;
}
/** Whether two paths or indexes go to the same view of the dashboard. */
protected isSameView(a?: string, b?: string): boolean {
const views = this._lovelace?.config.views;
return views
? findViewIndex(views, a) === findViewIndex(views, b)
: a === b;
}
// A path to one of the dashboard's views shows that view. Other paths leave
// the dashboard where it is.
protected navigateTo(path: string) {
const view = this.viewOfPath(path);
if (view !== undefined) {
this.view = view;
}
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
const raw = this.rawConfig;
const oldHass = this._previousHass;
this._previousHass = this._hass;
// Without a configuration, nothing is shown, and a generation still
// running is dropped
if (!raw) {
if (this._rawConfig) {
this._rawConfig = undefined;
this._lovelace = undefined;
this._generation++;
}
return;
}
if (!this._hass) {
return;
}
// A strategy is only asked whether to generate again when the backend
// changed, as in ha-panel-lovelace, not when the generated dashboard
// arrives
if (
raw !== this._rawConfig ||
(oldHass &&
oldHass !== this._hass &&
isStrategyDashboard(raw) &&
this._hass.config.state === "RUNNING" &&
checkStrategyShouldRegenerate(
"dashboard",
raw.strategy,
oldHass,
this._hass
))
) {
this._rawConfig = raw;
this._generate(raw, this._hass, ++this._generation);
}
}
private async _generate(
raw: LovelaceRawConfig,
hass: HomeAssistant,
generation: number
) {
const generated = isStrategyDashboard(raw)
? await generateLovelaceDashboardStrategy(raw, hass)
: raw;
const config = normalizeLovelaceConfig(generated);
// A newer generation has started. A generated dashboard has its views,
// rather than a strategy.
if (generation !== this._generation || isStrategyDashboard(config)) {
return;
}
this._lovelace = {
config,
rawConfig: raw,
editMode: false,
urlPath: null,
mode: "generated",
locale: hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
embedded: true,
};
}
// The background of a dashboard, and no insets for a notch or home bar
static styles: CSSResultGroup = [
embedElementStyles,
css`
:host {
display: block;
--app-safe-area-inset-top: 0px;
--app-safe-area-inset-bottom: 0px;
--app-safe-area-inset-left: 0px;
--app-safe-area-inset-right: 0px;
}
.theme {
position: relative;
background: var(--primary-background-color);
}
`,
];
}
+14
View File
@@ -15,3 +15,17 @@ export const readScriptData = (element: HTMLElement): unknown => {
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
+35 -1
View File
@@ -1,4 +1,4 @@
import { unsafeCSS } from "lit";
import { css, unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
@@ -59,3 +59,37 @@ export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
/**
* The styles of every embed element: the theme, and the page styles of the
* app, for the `.theme` wrapper of `EmbedElement`.
*/
export const embedElementStyles = [
embedThemeStyles,
css`
:host([hidden]) {
display: none;
}
/* The text styles the app sets on html and body, so that the content does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the content */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
+291 -9
View File
@@ -108,15 +108,24 @@
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<p>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and its
entities. These components render dashboard content with the backend of
a provider:
</p>
<ul>
<li><code>&lt;ha-embed-card&gt;</code> renders one card.</li>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
<code>&lt;ha-embed-section&gt;</code> renders one section, or a
section strategy.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
<code>&lt;ha-embed-view&gt;</code> renders one view, a view strategy,
or one view of a dashboard.
</li>
<li>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard, or a
dashboard strategy, with its tabs.
</li>
</ul>
<p>
@@ -324,6 +333,193 @@
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<p>
The other components find their provider the same way, and take the
same <code>provider</code> attribute.
</p>
<h2>Sections</h2>
<p>
<code>&lt;ha-embed-section&gt;</code> renders a section the same way as
in a sections view. The configuration is the same as in a dashboard,
so headings span the top, and a <code>strategy</code> generates the
section. The section takes the width of the component.
</p>
<pre><code>&lt;ha-embed-section&gt;
&lt;script type="text/yaml"&gt;
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen
- type: tile
entity: switch.kettle
&lt;/script&gt;
&lt;/ha-embed-section&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
entities:
- entity_id: light.kitchen_ceiling
state: "on"
device_id: kitchen_ceiling
attributes:
friendly_name: Ceiling
supported_color_modes: [brightness]
color_mode: brightness
brightness: 200
- entity_id: switch.kettle
state: "off"
area_id: kitchen
attributes:
friendly_name: Kettle
device_class: outlet
- entity_id: sensor.kitchen_temperature
state: "21.4"
area_id: kitchen
attributes:
friendly_name: Kitchen temperature
device_class: temperature
state_class: measurement
unit_of_measurement: °C
devices:
- id: kitchen_ceiling
name: Kitchen ceiling light
manufacturer: Acme
model: Smart bulb
area_id: kitchen
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-section>
<!-- htmlmin:ignore --><script type="text/yaml">
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen_ceiling
tap_action:
action: toggle
- type: tile
entity: switch.kettle
tap_action:
action: toggle
- type: tile
entity: sensor.kitchen_temperature
grid_options:
columns: 12
</script><!-- htmlmin:ignore -->
</ha-embed-section>
</div>
</ha-embed-provider>
<h2>Views</h2>
<p>
<code>&lt;ha-embed-view&gt;</code> renders a view, with its badges and
layout. A <code>strategy</code> generates the view. Give the component
the width the view needs: masonry and sidebar views pick their columns
from the width of the page, not the component.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
type: sections
badges:
- entity: sensor.outside_temperature
sections:
- cards: …
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<p>
To show one view of a dashboard, or of a dashboard strategy, put the
dashboard in <code>dashboard</code> and the path or index of the view
in <code>view</code>. Without <code>view</code>, the first view shows.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
dashboard:
strategy:
type: custom:my-dashboard
view: overview
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<ha-embed-view provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: sections
max_columns: 2
badges:
- entity: sensor.outside_temperature
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Hallway
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-view>
<h2>Dashboards</h2>
<p>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard with its
tabs. A <code>strategy</code> generates the dashboard. Each dashboard
keeps its own tab, so switching tabs does not change the address of
your page. The dashboard scrolls inside the component, under its
toolbar. The component is 600 pixels high; set its
<code>height</code> to change that. Set <code>view</code> to the path
or index of the first view to show.
</p>
<pre><code>&lt;ha-embed-dashboard style="height: 480px"&gt;
&lt;script type="text/yaml"&gt;
views:
- title: Home
path: home
cards: …
- title: Climate
path: climate
cards: …
&lt;/script&gt;
&lt;/ha-embed-dashboard&gt;</code></pre>
<ha-embed-dashboard provider="shared" style="height: 480px">
<!-- htmlmin:ignore --><script type="text/yaml">
views:
- title: Home
path: home
type: sections
max_columns: 2
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Outside
- type: tile
entity: sensor.outside_temperature
- title: Climate
path: climate
type: sections
sections:
- cards:
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-dashboard>
<h2>Light and dark mode</h2>
<p>
@@ -375,7 +571,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<code>provider.hass.states</code>.
</p>
<h2>Areas, calendars and energy</h2>
<h2>Areas, devices, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
@@ -385,7 +581,15 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities becomes an area.
<code>area_id</code> of the entities and devices becomes an area.
</li>
<li>
<code>devices</code>: a list of devices, each with an
<code>id</code> and a <code>name</code>, and optionally a
<code>manufacturer</code>, <code>model</code> and
<code>area_id</code>. Entities belong to a device with their
<code>device_id</code>, and cards and strategies then find the
device and its name.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
@@ -679,14 +883,92 @@ customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pr
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page.
provider around it, else the first provider on the page. The other
components take it too.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-section&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The section configuration or section strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-view&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The view configuration or view strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
<tr>
<td><code>dashboard</code></td>
<td>Attribute and property</td>
<td>
A dashboard configuration or dashboard strategy, to show one of its
views instead of <code>config</code>. A script child with a
<code>dashboard</code> key, and optionally <code>view</code>, sets
it.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the view of <code>dashboard</code> to show.
Without it, the first view.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-dashboard&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The dashboard configuration or dashboard strategy. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the first view to show. Without it, the first
view.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing.
such as <code>more-info</code>, do nothing. Dashboards can't be edited,
and their toolbar only shows their tabs.
</p>
</main>
<script>
@@ -59,6 +59,10 @@ const REGISTRY_ENTRIES = [
export const infraredFixtures: ConnectivityFixtures = {
components: ["infrared"],
manifests: [
manifest("infrared", "Infrared", {
config_flow: false,
integration_type: "entity",
}),
manifest("broadlink", "Broadlink", {
integration_type: "hub",
iot_class: "local_polling",
@@ -42,7 +42,13 @@ const REGISTRY_ENTRIES = [
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
manifests: [
manifest("radio_frequency", "Radio frequency", {
config_flow: false,
integration_type: "entity",
}),
manifest("esphome", "ESPHome", { integration_type: "device" }),
],
configEntries: [
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
],
+54
View File
@@ -1,6 +1,60 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/** The fields of a device that the demo devices leave at their default. */
export const baseDevice: Omit<
DeviceRegistryEntry,
| "id"
| "name"
| "manufacturer"
| "model"
| "config_entries"
| "primary_config_entry"
| "entry_type"
> = {
config_entries_subentries: {},
connections: [],
identifiers: [],
model_id: null,
labels: [],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export interface DemoDevice {
id: string;
name: string;
manufacturer?: string;
model?: string;
area_id?: string;
via_device_id?: string;
entry_type?: "service";
}
export const toDeviceRegistryEntry = (
device: DemoDevice
): DeviceRegistryEntry => ({
...baseDevice,
id: device.id,
name: device.name,
manufacturer: device.manufacturer ?? null,
model: device.model ?? null,
area_id: device.area_id ?? null,
via_device_id: device.via_device_id ?? null,
entry_type: device.entry_type ?? null,
config_entries: [],
primary_config_entry: null,
});
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
+1 -19
View File
@@ -1,24 +1,6 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
import { baseDevice } from "./device_registry";
export const demoDevices: DeviceRegistryEntry[] = [
{
+9
View File
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
stat_consumption: "sensor.water_garden",
},
],
device_consumption_gas: [
{
stat_consumption: "sensor.gas_kitchen",
},
{
stat_consumption: "sensor.gas_heat_pump",
},
],
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
device_consumption_gas: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
+20
View File
@@ -160,6 +160,26 @@ export const energyEntities = () =>
unit_of_measurement: "L",
},
},
"sensor.gas_kitchen": {
entity_id: "sensor.gas_kitchen",
state: "100",
attributes: {
friendly_name: "Gas kitchen",
unit_of_measurement: "m³",
device_class: "gas",
state_class: "total_increasing",
},
},
"sensor.gas_heat_pump": {
entity_id: "sensor.gas_heat_pump",
state: "500",
attributes: {
friendly_name: "Gas heat pump",
unit_of_measurement: "m³",
device_class: "gas",
state_class: "total_increasing",
},
},
"sensor.energy_car": {
entity_id: "sensor.energy_car",
state: "4",
+18
View File
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
100,
(bucketStart, bucketEnd) =>
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
500,
(bucketStart, bucketEnd) =>
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
+8
View File
@@ -301,6 +301,14 @@ export default tseslint.config(
globals: globals.node,
},
},
{
// Published as native ESM, where package subpath imports such as
// "lit/decorators.js" need their extension.
files: ["packages/*/src/**/*.ts"],
rules: {
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
},
},
{
plugins: {
html,
+4
View File
@@ -81,6 +81,10 @@ export default [
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Charts",
pages: ["state-history-charts", "statistics-chart"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
+30
View File
@@ -48,6 +48,36 @@ export const createMediaPlayerEntities = () => [
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.music_all_features",
state: "playing",
attributes: {
friendly_name: "Playing The Music (all features)",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + On + Off +
// Play Media + Volume Step + Select Source + Stop + Clear + Play + Shuffle Set +
// Sound Mode + Browse Media + Repeat Set + Grouping
supported_features: 1048511,
entity_picture: "/images/album_cover.jpg",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
// 23 seconds in
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
is_volume_muted: false,
shuffle: false,
repeat: "off",
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.stream_playing",
state: "playing",
+2 -2
View File
@@ -4,7 +4,7 @@ title: "Our story"
## Open source home automation that puts local control and privacy first
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
@@ -50,6 +50,18 @@ With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
row, or from the top match while searching. With one item in the list,
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
## Screen readers
DOM focus stays in the search field, so screen readers do not hear the list
change. While searching, a visually hidden `role="status"` message says how
many items match the search and names it, so a new search with the same count
is still read out. It is cleared as soon as the list changes and filled in once
typing pauses for half a second, so it doesn't talk over each typed character
or describe the previous search. When nothing matches, it reads a not
found label that takes the search, or says there are no results for it. Section
titles, placeholder rows, "Add custom item", the additional items and rows that
`isSearchResult` rejects are not counted.
## Implementation
Without `sections`, the component filters the items from `getItems` itself as the
@@ -79,6 +91,7 @@ means supplying one.
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| isSearchResult | Function | - | Whether a row from `getItems` counts as a match for screen readers. The quick bar leaves out its "Ask Assist" row. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
@@ -1,7 +1,9 @@
import { ContextProvider } from "@lit/context";
import { IntlMessageFormat } from "intl-messageformat";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-picker-combo-box";
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
@@ -19,13 +21,26 @@ import type {
} from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
// The English strings, plurals included, so searching a demo announces the
// same count and search as the real picker.
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.search": "Search",
"ui.components.combo-box.no_match": "No matching items found",
"ui.components.combo-box.no_items": "No items available",
"ui.components.combo-box.result_count":
"{count} {count, plural,\n one {result}\n other {results}\n} for {term}",
"ui.components.combo-box.no_results": "No results for {term}",
};
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const localize: LocalizeFunc = (key, values) => {
const message = LOCALIZE_KEYS[key];
if (message === undefined) {
return key;
}
return new IntlMessageFormat(message, "en").format<string>(
values as Record<string, string | number>
) as string;
};
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
+7 -2
View File
@@ -683,8 +683,13 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class",
selector: { device_class: { domain: "sensor" } },
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
},
sensor_state_class: {
name: "State class",
@@ -0,0 +1,6 @@
---
title: History charts
subtitle: State history as timelines and line charts.
---
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
@@ -0,0 +1,211 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/state-history-charts";
import "../../../../src/components/ha-card";
import type {
HistoryResult,
LineChartState,
TimelineState,
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const ENTITIES = [
{
entity_id: "sensor.outdoor_temperature",
state: "12.4",
attributes: {
friendly_name: "Outdoor temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
{
entity_id: "light.living_room",
state: "on",
attributes: { friendly_name: "Living room light" },
},
{
entity_id: "binary_sensor.front_door",
state: "off",
attributes: { friendly_name: "Front door", device_class: "door" },
},
];
const temperatureStates = (): LineChartState[] =>
Array.from({ length: HOURS * 2 }, (_, index) => {
const time = START.getTime() + (index * HOUR) / 2;
const hourOfDay = new Date(time).getHours();
const value =
10 +
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.5 * Math.sin(time / HOUR);
return {
state: value.toFixed(1),
last_changed: time,
};
});
const toggleStates = (
onHours: [number, number][],
onLabel: string,
offLabel: string
): TimelineState[] => {
const on = { state: "on", state_localize: onLabel };
const off = { state: "off", state_localize: offLabel };
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
onHours.forEach(([from, to]) => {
states.push(
{ ...on, last_changed: START.getTime() + from * HOUR },
{ ...off, last_changed: START.getTime() + to * HOUR }
);
});
return states;
};
const HISTORY: HistoryResult = {
line: [
{
unit: "°C",
device_class: "temperature",
identifier: "temperature",
data: [
{
domain: "sensor",
name: "Outdoor temperature",
entity_id: "sensor.outdoor_temperature",
states: temperatureStates(),
},
],
},
],
timeline: [
{
name: "Living room light",
entity_id: "light.living_room",
data: toggleStates(
[
[6, 8],
[18, 23],
[30, 32],
[42, 47],
],
"On",
"Off"
),
},
{
name: "Front door",
entity_id: "binary_sensor.front_door",
data: toggleStates(
[
[7.5, 7.6],
[17, 17.2],
[31.5, 31.6],
[41, 41.1],
],
"Open",
"Closed"
),
},
],
};
const LIGHT_ONLY: HistoryResult = {
line: [],
timeline: [HISTORY.timeline[0]],
};
interface Example {
title: string;
description: string;
history: HistoryResult;
showNames?: boolean;
insideLabels?: boolean;
syncCharts?: boolean;
}
const EXAMPLES: Example[] = [
{
title: "Names in a column",
description:
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
history: HISTORY,
},
{
title: "Names inside the timeline, synced zoom",
description:
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
history: HISTORY,
insideLabels: true,
syncCharts: true,
},
{
title: "Single entity without names",
description:
"Like the more-info dialog, where the entity is already known.",
history: LIGHT_ONLY,
showNames: false,
},
];
@customElement("demo-components-state-history-charts")
export class DemoStateHistoryCharts extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<state-history-charts
.historyData=${example.history}
.startTime=${START}
.endTime=${END}
.showNames=${example.showNames ?? true}
.insideLabels=${example.insideLabels ?? false}
.syncCharts=${example.syncCharts ?? false}
.clickForMoreInfo=${false}
></state-history-charts>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-state-history-charts": DemoStateHistoryCharts;
}
}
@@ -0,0 +1,6 @@
---
title: Statistics chart
subtitle: Long-term statistics as a bar or line chart.
---
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
@@ -0,0 +1,212 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-card";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
StatisticValue,
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const round = (value: number, digits: number) =>
Math.round(value * 10 ** digits) / 10 ** digits;
const hourlyValues = (
compute: (start: number) => Partial<StatisticValue>
): StatisticValue[] =>
Array.from({ length: HOURS }, (_, hour) => {
const start = START.getTime() + hour * HOUR;
return { start, end: start + HOUR, ...compute(start) };
});
const energyValues = (dayUse: number, eveningUse: number) => {
let sum = 0;
return hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
sum += change;
return { change, sum: round(sum, 2) };
});
};
const temperatureValues = (average: number, swing: number) =>
hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const mean =
average +
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.3 * Math.sin(start / HOUR);
return {
mean: round(mean, 1),
min: round(mean - 0.4, 1),
max: round(mean + 0.4, 1),
};
});
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
const LIGHTING_ID = "sensor.lighting_energy";
const LIVING_ROOM_ID = "sensor.living_room_temperature";
const BEDROOM_ID = "sensor.bedroom_temperature";
const HEAT_PUMP = energyValues(0.4, 1.2);
const LIGHTING = energyValues(0.05, 0.3);
const LIVING_ROOM = temperatureValues(21, 1.5);
const BEDROOM = temperatureValues(18.5, 1);
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "kWh",
source: "recorder",
name,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
});
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "°C",
source: "recorder",
name,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
});
const METADATA: Record<string, StatisticsMetaData> = {
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
};
const NAMES = Object.fromEntries(
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
);
interface Example {
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
unit: string;
hideLegend?: boolean;
height?: string;
}
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
description:
"The change of a sum for each hour, like an energy statistics graph card.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
},
{
title: "Stacked bars",
description: "Several sums stacked, to compare what each one adds.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
statTypes: ["change"],
chartType: "bar-stack",
unit: "kWh",
},
{
title: "Mean, min and max",
description: "A measurement with its hourly mean, min and max.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
},
{
title: "Several statistics",
description: "The mean of several measurements with the same unit.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
statTypes: ["mean"],
chartType: "line",
unit: "°C",
},
{
title: "Without legend",
description:
"A single statistic with a fixed height, like in the more-info dialog.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
hideLegend: true,
height: "240px",
},
];
@customElement("demo-components-statistics-chart")
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.hideLegend=${example.hideLegend ?? false}
.height=${example.height}
.clickForMoreInfo=${false}
></statistics-chart>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-statistics-chart": DemoStatisticsChart;
}
}
@@ -25,6 +25,13 @@ const CONFIGS = [
entity: "media_player.music_playing",
},
},
{
heading: "Playing Music (all features)",
config: {
type: "media-control",
entity: "media_player.music_all_features",
},
},
{
heading: "Playing Stream",
config: {
+114
View File
@@ -201,6 +201,50 @@ const ENTITIES = [
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.on_off_fan",
state: "on",
attributes: {
friendly_name: "Bathroom fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.six_speed_fan",
state: "on",
attributes: {
friendly_name: "Kitchen fan",
device_class: "fan",
percentage: 50,
percentage_step: 100 / 6,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.ten_speed_fan",
state: "on",
attributes: {
friendly_name: "Living room fan",
device_class: "fan",
percentage: 70,
percentage_step: 10,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.eleven_speed_fan",
state: "on",
attributes: {
friendly_name: "Office fan",
device_class: "fan",
percentage: 45,
percentage_step: 100 / 11,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -410,6 +454,55 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds, slider style)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
config: {
type: "tile",
entity: "fan.on_off_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (6 speeds, slider by default)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (6 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (10 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading:
"Fan speed feature (11 speeds, buttons style falls back to slider)",
config: {
type: "tile",
entity: "fan.eleven_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
@@ -430,6 +523,27 @@ const CONFIGS = [
],
},
},
{
heading:
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
{
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
+1 -1
View File
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
["tuya", "Tuya", "setup_error"],
["unifi", "UniFi Network", "not_loaded"],
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
["google_assistant", "Google Assistant", "not_loaded"],
["google_assistant", "Google Home", "not_loaded"],
["homekit_controller", "HomeKit Device", "setup_retry"],
] as [string, string, ConfigEntryExtended["state"]][]
).map(([domain, name, entryState]) =>
+35 -34
View File
@@ -13,7 +13,7 @@
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.8",
"@babel/runtime": "8.1.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -54,17 +54,17 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.2",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.15",
"@formatjs/intl-locale": "5.3.12",
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.16",
"@formatjs/intl-relativetimeformat": "12.3.16",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.4",
"@lezer/common": "1.5.5",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -78,11 +78,11 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "7.0.1",
"@tsparticles/engine": "4.5.1",
"@tsparticles/preset-links": "4.5.1",
"@versatiles/style": "8.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
@@ -98,10 +98,10 @@
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fullcalendar": "7.1.4",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -110,7 +110,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.1",
"marked": "18.1.3",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -123,7 +123,7 @@
"sortablejs": "1.15.7",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"temporal-polyfill": "1.0.6",
"temporal-polyfill": "1.0.7",
"tinykeys": "4.0.1",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
@@ -137,24 +137,25 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.9",
"@babel/core": "8.1.0",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@bundle-stats/plugin-webpack-filter": "4.23.1",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@home-assistant/components": "workspace:*",
"@html-eslint/eslint-plugin": "0.67.0",
"@lokalise/node-api": "17.0.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@rspack/core": "2.3.1",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
"@types/chromecast-caf-sender": "1.0.11",
@@ -170,14 +171,14 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.1.0",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.4",
"browserslist": "4.29.5",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.51.0",
"del": "9.0.1",
"eslint": "10.13.0",
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -188,8 +189,8 @@
"fancy-log": "2.0.0",
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"globals": "17.13.0",
"glob": "14.0.3",
"globals": "17.14.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -214,16 +215,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.0",
"typescript-eslint": "8.72.1",
"vite": "8.3.5",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]5.0",
"packageManager": "[email protected]7.0",
"volta": {
"node": "24.22.1"
"node": "24.22.2"
}
}
+156
View File
@@ -0,0 +1,156 @@
# @home-assistant/components
Home Assistant Components (`hac-*`): framework-agnostic web components built
with [Lit](https://lit.dev).
> Work in progress, not published yet.
## Registration
Every element is imported on its own, in one of two ways:
| Import | Registers in | For |
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
`<element>` is the tag name without the `hac-` prefix, like `card` for
`hac-card`.
Both use the copy bundled with your code. A scoped copy is isolated from the
one Home Assistant registers globally, so a custom card keeps the version it
was built with.
Global registration skips tag names that are already registered, so the first
registration wins and nothing throws.
### Global
```ts
import "@home-assistant/components/card";
```
### Scoped
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
`createScopedElementRegistry` for plain custom elements. Forgetting the
elements is a TypeScript error, and a runtime error in plain JavaScript.
Lit:
```ts
import { LitElement, html } from "lit";
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
import { hacCard } from "@home-assistant/components/card/scoped";
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
render() {
return html`<hac-card>…</hac-card>`;
}
}
```
Plain custom elements:
```js
import {
attachScopedShadow,
createScopedElement,
createScopedElementRegistry,
} from "@home-assistant/components/scoped";
import { hacCard } from "@home-assistant/components/card/scoped";
// Create the registry once, outside the class: all instances of the card share it
const registry = createScopedElementRegistry([hacCard]);
class MyCard extends HTMLElement {
constructor() {
super();
const root = attachScopedShadow(this, { mode: "open" }, registry);
root.innerHTML = "<hac-card>…</hac-card>";
// or: root.append(createScopedElement(root, "hac-card"));
}
}
```
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
registry. `document.createElement` uses the global one, so avoid it for
scoped elements.
Scoped registries need native support (Chrome 146+, Safari 26) or
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
Without either, elements are registered globally instead, and an already
registered tag name keeps its existing class.
Only elements rendered inside the shadow root use the scoped registry. Light
DOM content, such as slotted elements, uses the registry of the tree it is
written in.
`lit` is a peer dependency.
### TypeScript
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
`document.querySelector("hac-card")` and templates are typed once the element
is imported. For the class itself, import its type from the `scoped` entry
point; `import type` loads nothing at runtime:
```ts
import type { HacCard } from "@home-assistant/components/card/scoped";
```
The package has no root entry point: each element and the scoped helpers
(`@home-assistant/components/scoped`) have their own.
## Theming
Each component exposes its own `--hac-*` custom properties, documented in its
source, which take precedence. Next come the theme variables of the existing
Home Assistant element it replaces, so current themes keep working: `hac-card`
reads the variables of `ha-card` (`--ha-card-background`,
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
their light values, so the components also render outside Home Assistant.
The legacy theme variables are a temporary bridge for this experiment, not the
target design. Home Assistant always defines some of them, like
`--card-background-color`, so inside Home Assistant they currently win over
the semantic tokens. Once themes move to the semantic tokens, the legacy
variables will be removed from the fallbacks.
## Structure
Each element has its own directory, named like its import path:
```
src/components/card/ <hac-card> -> @home-assistant/components/card
hac-card.ts class, does not register itself
info.ts tag name and version
global.ts registers globally
scoped.ts element for a scoped registry -> …/card/scoped
```
Shared code lives in `src/utils/`: `registration/` for defining elements
(global and scoped) and `element/` for helpers that components use
themselves, like slot checks.
Every element has its own version in `info.ts`.
Entry points are mapped with subpath patterns in `package.json`, so a new
element directory needs no `package.json` change. Only `global.ts` files have
side effects, which `sideEffects` relies on for tree shaking.
## Development
```bash
pnpm --filter @home-assistant/components build # ESM, type declarations and custom-elements.json to dist/
pnpm --filter @home-assistant/components dev # rebuild on change
```
Inside this repository the app uses the sources directly through
the `ha-source` export condition, so no build is needed during development.
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
source file becomes its own ES module. The build then generates a
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
the JSDoc of each element, so document new slots, parts, CSS properties and
events there.
@@ -0,0 +1,84 @@
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
// editors and documentation tools. Only hac-*.ts files declare elements; the
// entry points just re-export them.
const forEachDeclaration = (customElementsManifest, callback) => {
for (const module of customElementsManifest.modules) {
for (const declaration of module.declarations ?? []) {
callback(declaration);
}
}
};
// The analyzer leaves the readme empty. Paths in the manifest are relative to
// the package root, like the module paths.
const packageReadme = () => ({
name: "package-readme",
packageLinkPhase({ customElementsManifest }) {
customElementsManifest.readme = "README.md";
},
});
// Elements are documented with @summary, which the analyzer only puts into
// `summary`. Most tools show `description`, so fall back to the summary.
const summaryAsDescription = () => ({
name: "summary-as-description",
packageLinkPhase({ customElementsManifest }) {
forEachDeclaration(customElementsManifest, (declaration) => {
if (!declaration.description && declaration.summary) {
declaration.description = declaration.summary;
}
});
},
});
// Private and protected members are internal, so keep them out of the
// documented API.
const publicApiOnly = () => ({
name: "public-api-only",
packageLinkPhase({ customElementsManifest }) {
forEachDeclaration(customElementsManifest, (declaration) => {
declaration.members = declaration.members?.filter(
(member) => !member.privacy || member.privacy === "public"
);
});
},
});
const distPaths = () => {
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
const rewrite = (node) => {
if (Array.isArray(node)) {
node.forEach(rewrite);
return;
}
if (!node || typeof node !== "object") {
return;
}
for (const [key, value] of Object.entries(node)) {
if ((key === "path" || key === "module") && typeof value === "string") {
node[key] = toDist(value);
} else {
rewrite(value);
}
}
};
return {
name: "dist-paths",
packageLinkPhase({ customElementsManifest }) {
rewrite(customElementsManifest.modules);
},
};
};
export default {
globs: ["src/components/**/hac-*.ts"],
outdir: "dist",
litelement: true,
plugins: [
packageReadme(),
summaryAsDescription(),
publicApiOnly(),
distPaths(),
],
};
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@home-assistant/components",
"version": "0.0.0",
"description": "Home Assistant web components (hac-* elements) built with Lit",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/home-assistant/frontend.git",
"directory": "packages/components"
},
"type": "module",
"sideEffects": [
"./dist/components/**/global.js",
"./src/components/**/global.ts"
],
"customElements": "dist/custom-elements.json",
"exports": {
"./scoped": {
"ha-source": "./src/scoped.ts",
"types": "./dist/scoped.d.ts",
"import": "./dist/scoped.js"
},
"./package.json": "./package.json",
"./*/scoped": {
"ha-source": "./src/components/*/scoped.ts",
"types": "./dist/components/*/scoped.d.ts",
"import": "./dist/components/*/scoped.js"
},
"./*/info": {
"ha-source": "./src/components/*/info.ts",
"types": "./dist/components/*/info.d.ts",
"import": "./dist/components/*/info.js"
},
"./*": {
"ha-source": "./src/components/*/global.ts",
"types": "./dist/components/*/global.d.ts",
"import": "./dist/components/*/global.js"
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "rslib build && cem analyze",
"dev": "rslib build --watch",
"prepublishOnly": "pnpm run build"
},
"peerDependencies": {
"lit": "^3.3.0"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "0.11.0",
"@rslib/core": "1.0.5",
"lit": "3.3.3",
"typescript": "6.0.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "@rslib/core";
// Bundleless build: every source file becomes its own ESM file, so consumers'
// bundlers only include the entry points they import. Only global.ts files
// register elements; everything else is free of side effects.
export default defineConfig({
source: {
entry: { index: ["./src/**/*.ts"] },
decorators: { version: "legacy" },
tsconfigPath: "./tsconfig.json",
},
tools: {
swc: { jsc: { transform: { decoratorMetadata: false } } },
},
lib: [
{
format: "esm",
bundle: false,
syntax: "es2021",
dts: true,
output: { target: "web", distPath: { root: "./dist" } },
},
],
});
@@ -0,0 +1,8 @@
import { define } from "../../utils/registration/define";
import { HacCard } from "./hac-card";
import { TAG_NAME } from "./info";
// Registers <hac-card> in the global registry, as used by Home Assistant
define(TAG_NAME, HacCard);
export { HacCard };
@@ -0,0 +1,165 @@
import { css, html, LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import {
hasAssignedContent,
hasAssignedElements,
} from "../../utils/element/slot";
/**
* @element hac-card
*
* @summary
* Card that groups related content, with an optional header and footer.
*
* @slot header - Header of the card. Its spacing replaces the top padding of
* the content.
* @slot - Content of the card. Its padding is omitted when empty.
* @slot footer - Footer of the card. Hidden when empty.
*
* @csspart header - Wrapper of the header slot.
* @csspart content - Wrapper of the default slot.
* @csspart footer - Wrapper of the footer slot.
*
* @cssprop --hac-card-background - Background of the card.
* @cssprop --hac-card-color - Text color of the card.
* @cssprop --hac-card-border-color - Border color of the card and the footer divider.
* @cssprop --hac-card-border-width - Border width of the card.
* @cssprop --hac-card-border-radius - Border radius of the card.
* @cssprop --hac-card-box-shadow - Box shadow of the card.
* @cssprop --hac-card-content-padding - Padding of the content.
* @cssprop --hac-card-footer-padding - Padding of the footer.
*/
export class HacCard extends LitElement {
/**
* Visual style of the card. `outlined` draws a border without a shadow.
* `raised` hides the border and lifts the card with a shadow instead.
*/
@property({ reflect: true }) public appearance: "outlined" | "raised" =
"outlined";
@state() private _hasHeader = false;
@state() private _hasFooter = false;
@state() private _hasContent = false;
protected render() {
return html`
<div class="header" part="header" ?hidden=${!this._hasHeader}>
<slot name="header" @slotchange=${this._headerChanged}></slot>
</div>
<div
class="content ${this._hasHeader ? "below-header" : ""}"
part="content"
?hidden=${!this._hasContent}
>
<slot @slotchange=${this._contentChanged}></slot>
</div>
<div class="footer" part="footer" ?hidden=${!this._hasFooter}>
<slot name="footer" @slotchange=${this._footerChanged}></slot>
</div>
`;
}
private _headerChanged(ev: Event) {
this._hasHeader = hasAssignedElements(ev);
}
private _contentChanged(ev: Event) {
this._hasContent = hasAssignedContent(ev);
}
private _footerChanged(ev: Event) {
this._hasFooter = hasAssignedElements(ev);
}
/*
* Fallback order: --hac-card-*, then the theme variables of ha-card
* (--ha-card-*, --card-background-color, --divider-color,
* --primary-text-color) so existing themes keep working, then the semantic
* tokens and their light values for use outside Home Assistant.
* The ha-card variables are temporary, see "Theming" in the README.
*/
static styles = css`
:host {
display: block;
position: relative;
box-sizing: border-box;
background: var(
--hac-card-background,
var(
--ha-card-background,
var(--card-background-color, var(--ha-color-surface-default, #fff))
)
);
backdrop-filter: var(--ha-card-backdrop-filter, none);
color: var(
--hac-card-color,
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
);
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
solid
var(
--hac-card-border-color,
var(
--ha-card-border-color,
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
)
);
border-radius: var(
--hac-card-border-radius,
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
);
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
}
:host([appearance="raised"]) {
border-color: transparent;
box-shadow: var(
--hac-card-box-shadow,
var(
--ha-card-box-shadow,
var(
--ha-box-shadow-s,
0 1px 2px 0 rgba(0, 0, 0, 0.08),
0 1px 3px 0 rgba(0, 0, 0, 0.12)
)
)
);
}
.content {
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
}
/* The header already provides the spacing above the content */
.content.below-header {
padding-block-start: 0;
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--ha-space-2, 8px);
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
border-block-start: 1px solid
var(
--hac-card-border-color,
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
);
}
.header[hidden],
.content[hidden],
.footer[hidden] {
display: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hac-card": HacCard;
}
}
@@ -0,0 +1,5 @@
/** Tag name of the element. */
export const TAG_NAME = "hac-card";
/** Version of <hac-card>. */
export const VERSION = "0.1.0";
@@ -0,0 +1,15 @@
import type { ElementDefinition } from "../../utils/registration/definition";
import { HacCard } from "./hac-card";
import { TAG_NAME } from "./info";
export { HacCard };
/**
* The bundled <hac-card> for a scoped registry:
* `ScopedElementsMixin(LitElement, [hacCard])` or
* `createScopedElementRegistry([hacCard])`.
*/
export const hacCard: ElementDefinition<typeof HacCard> = {
tagName: TAG_NAME,
element: HacCard,
};
+132
View File
@@ -0,0 +1,132 @@
import type { LitElement } from "lit";
import { define } from "./utils/registration/define";
import type { ElementDefinition } from "./utils/registration/definition";
import {
createScopedRegistry,
scopedCreationScope,
scopedRegistrySupport,
scopedShadowRootInit,
} from "./utils/registration/scoped-registry";
export type { ElementDefinition } from "./utils/registration/definition";
/**
* Creates a registry with the given elements, for `attachScopedShadow`:
* definitions from `scoped` entry points, e.g. `[hacCard]`.
* Create it once, outside the class, so all instances of a card share it.
*
* Without scoped registry support (native or polyfill), the elements are
* registered in the global registry instead, unless their tag name is already
* taken there, and the global registry is returned.
*/
export const createScopedElementRegistry = (
elements: readonly ElementDefinition[]
): CustomElementRegistry => {
// Checked as unknown: Array.isArray would narrow the list to any[]
if (!Array.isArray(elements as unknown)) {
throw new TypeError(
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
);
}
const registry = createScopedRegistry() ?? customElements;
for (const { tagName, element } of elements) {
define(tagName, element, registry);
}
return registry;
};
const shadowRootInit = (
init: ShadowRootInit,
registry: CustomElementRegistry
): ShadowRootInit =>
registry === customElements ? init : scopedShadowRootInit(init, registry);
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
/**
* Attaches a shadow root that uses `registry` for custom elements in it.
*
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
* Create single elements with `createScopedElement`; `document.createElement`
* would use the global registry.
*
* Light DOM content, like slotted elements, keeps using the registry of the
* tree it is written in.
*/
export const attachScopedShadow = (
host: HTMLElement,
init: ShadowRootInit,
registry: CustomElementRegistry
): ShadowRoot => {
const root = host.attachShadow(shadowRootInit(init, registry));
registries.set(root, registry);
return root;
};
/** Creates an element from the registry of a shadow root. */
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
root: ShadowRoot,
tagName: K
): HTMLElementTagNameMap[K] => {
const registry = registries.get(root);
if (!registry || registry === customElements) {
return document.createElement(tagName);
}
if (scopedRegistrySupport() === "polyfill") {
// The polyfill adds createElement to shadow roots
return (
root as unknown as {
createElement(tagName: K): HTMLElementTagNameMap[K];
}
).createElement(tagName);
}
return (
document.createElement as (
tagName: K,
options: { customElementRegistry: CustomElementRegistry }
) => HTMLElementTagNameMap[K]
)(tagName, { customElementRegistry: registry });
};
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
/**
* Renders a Lit element into a shadow root with its own custom element
* registry, containing `elements`:
*
* ```ts
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
* ```
*
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
*/
export const ScopedElementsMixin = <T extends LitElementConstructor>(
superClass: T,
elements: readonly ElementDefinition[]
) => {
// Created on first use, so defining the class has no side effects
let registry: CustomElementRegistry | undefined;
const getRegistry = () => {
registry ??= createScopedElementRegistry(elements);
return registry;
};
abstract class ScopedElementsHost extends superClass {
public attachShadow(init: ShadowRootInit): ShadowRoot {
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
registries.set(root, getRegistry());
return root;
}
protected createRenderRoot() {
const root = super.createRenderRoot();
const scoped = getRegistry();
if (scoped !== customElements && root instanceof ShadowRoot) {
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
}
return root;
}
}
// Typed as the base class: the overrides add no public API
return ScopedElementsHost as unknown as T;
};
@@ -0,0 +1,15 @@
/** Whether the slot of a `slotchange` event has elements assigned. */
export const hasAssignedElements = (ev: Event): boolean =>
(ev.target as HTMLSlotElement).assignedElements().length > 0;
/**
* Whether the slot of a `slotchange` event has content assigned: elements or
* text. Whitespace between tags is not content.
*/
export const hasAssignedContent = (ev: Event): boolean =>
(ev.target as HTMLSlotElement)
.assignedNodes()
.some(
(node) =>
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
);
@@ -0,0 +1,17 @@
/**
* Registers a custom element unless the tag name is already taken in the
* registry.
*
* Home Assistant and custom cards can each ship a copy of this library, so
* registering the same tag name twice must not throw. The first registration
* wins.
*/
export const define = (
tagName: string,
elementClass: CustomElementConstructor,
registry: CustomElementRegistry = customElements
): void => {
if (!registry.get(tagName)) {
registry.define(tagName, elementClass);
}
};
@@ -0,0 +1,10 @@
/**
* An element to register: its tag name and its class. The `scoped` entry
* point of an element exports one, e.g. `hacCard`.
*/
export interface ElementDefinition<
E extends CustomElementConstructor = CustomElementConstructor,
> {
readonly tagName: string;
readonly element: E;
}
@@ -0,0 +1,67 @@
/**
* Scoped custom element registries come in two flavors: the standard API
* (Chrome 146+, Safari 26) and the older API of
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
* and which replaces the native implementation when present.
*/
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
let support: ScopedRegistrySupport | undefined;
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
if (support) {
return support;
}
if ("CustomElementRegistryPolyfill" in globalThis) {
support = "polyfill";
} else {
try {
new CustomElementRegistry();
support = "native";
} catch (_err) {
support = "none";
}
}
return support;
};
/** A new scoped registry, or `undefined` if the browser has no support. */
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
/** Shadow root options that make the shadow root use `registry`. */
export const scopedShadowRootInit = (
init: ShadowRootInit,
registry: CustomElementRegistry
): ShadowRootInit =>
scopedRegistrySupport() === "polyfill"
? ({ ...init, registry } as ShadowRootInit)
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
interface CreationScope {
importNode(node: Node, deep?: boolean): Node;
}
/**
* The object Lit should create template content with, so custom elements in
* it are created from the scoped registry (Lit's `creationScope`).
*/
export const scopedCreationScope = (
root: ShadowRoot,
registry: CustomElementRegistry
): CreationScope => {
if (scopedRegistrySupport() === "polyfill") {
// The polyfill adds importNode to shadow roots
return root as unknown as CreationScope;
}
// Standard API: the registry is passed to importNode
return {
importNode: (node, deep) =>
(
document.importNode as (
node: Node,
options: { selfOnly: boolean; customElementRegistry: unknown }
) => Node
)(node, { selfOnly: !deep, customElementRegistry: registry }),
};
};
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { define } from "../src/utils/registration/define";
// Home Assistant and custom cards can each ship a copy of the library, so a
// second registration of the same tag must be ignored instead of throwing.
describe("define", () => {
it("registers an element that is not registered yet", () => {
class First extends HTMLElement {}
define("hac-test-first", First);
expect(customElements.get("hac-test-first")).toBe(First);
});
it("keeps the first registration of a tag name", () => {
class Original extends HTMLElement {}
class Duplicate extends HTMLElement {}
define("hac-test-duplicate", Original);
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
expect(customElements.get("hac-test-duplicate")).toBe(Original);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
hasAssignedContent,
hasAssignedElements,
} from "../src/utils/element/slot";
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
// Whitespace between tags must not count as content.
const slotEvent = (light: string, name?: string) => {
const host = document.createElement("div");
host.innerHTML = light;
const slot = document.createElement("slot");
if (name) {
slot.name = name;
}
host.attachShadow({ mode: "open" }).append(slot);
return { target: slot } as unknown as Event;
};
describe("hasAssignedContent", () => {
it("ignores whitespace", () => {
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
});
it("counts text", () => {
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
});
it("counts elements", () => {
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
});
});
describe("hasAssignedElements", () => {
it("only counts elements assigned to the slot", () => {
const light = '<span slot="icon"></span> text';
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"isolatedModules": true,
"skipLibCheck": true,
"declaration": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+63 -4
View File
@@ -1,8 +1,8 @@
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
--- a/modular/sortable.core.esm.js
+++ b/modular/sortable.core.esm.js
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
}
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
capture();
@@ -24,7 +24,7 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
}
parentEl = el; // actualization
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
targetRect = getRect(target);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
capture();
@@ -38,7 +38,16 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
parentEl = el; // actualization
changed();
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
pastFirstInvertThresh = false;
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
var sibling;
if (direction !== 0) {
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
_silent = true;
setTimeout(_unsilent, 30);
capture();
@@ -58,3 +67,53 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
}
// Undo chrome's scroll adjustment (has no effect on other browsers)
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
function _unsilent() {
_silent = false;
}
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
+// checks below keep assuming that the list runs from left to right.
+function _isRtl(el, vertical) {
+ return !vertical && css(el, 'direction') === 'rtl';
+}
+function _mirrorX(rect) {
+ return {
+ left: -rect.right,
+ right: -rect.left,
+ top: rect.top,
+ bottom: rect.bottom,
+ width: rect.width,
+ height: rect.height
+ };
+}
function _ghostIsFirst(evt, vertical, sortable) {
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ firstElRect = _mirrorX(firstElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
}
function _ghostIsLast(evt, vertical, sortable) {
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ lastElRect = _mirrorX(lastElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
}
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
+ if (rtl) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ targetRect = _mirrorX(targetRect);
+ }
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
targetLength = vertical ? targetRect.height : targetRect.width,
targetS1 = vertical ? targetRect.top : targetRect.left,
+1709 -1017
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -1,3 +1,7 @@
packages:
- "."
- "packages/*"
savePrefix: ""
autoInstallPeers: false
@@ -19,6 +23,7 @@ trustPolicyExclude:
allowBuilds:
"@tsparticles/engine": false
core-js: false
rs-module-lexer: false
unrs-resolver: false
overrides:
@@ -26,7 +31,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
tslib: 2.8.1
tslib: 2.9.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
@@ -0,0 +1,8 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 582 B

@@ -0,0 +1,8 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="28" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="48" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="68" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 584 B

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20261028.4"
version = "20261028.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+18 -1
View File
@@ -13,6 +13,9 @@
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["pnpmDedupe"],
"constraints": {
"ghActionsLock": "v0.4.1"
},
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
"enabled": true,
@@ -59,12 +62,26 @@
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
"customType": "regex",
"managerFilePatterns": [
"/^\\.github/workflows/actions-lock\\.yaml$/",
"/^renovate\\.json$/"
],
"matchStrings": [
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
],
"depNameTemplate": "github/gh-actions-lock",
"datasourceTemplate": "github-releases"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Return a copy of the list with the value in it, without adding duplicates
* @param values - The current list
* @param value - The value to add
*/
export const arrayWith = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values?.includes(value) ? [...values] : [...(values ?? []), value]);
/**
* Return a copy of the list without the value
* @param values - The current list
* @param value - The value to remove
*/
export const arrayWithout = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values ?? []).filter((v) => v !== value);
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
const MAX_ROWS = 2;
// A narrow list, as on a phone or a half width card, fits about one item per
// row, so two rows hide most of the legend. 450px is about the widest list a
// phone viewport gives.
const NARROW_MAX_ROWS = 3;
const NARROW_WIDTH = 450;
// How many items fit in maxRows of a wrapping row, leaving room for the
// "more" item after them when not all of them fit.
export const fitInRows = (
@@ -34,9 +40,10 @@ export const fitInRows = (
return widths.length;
};
// Fits the items of a wrapping legend list into two rows. The list keeps its
// "more" item rendered with the legend-more class, and items past the fit
// stay rendered out of the flow, so both can always be measured.
// Fits the items of a wrapping legend list into two rows, three when it is
// narrow. The list keeps its "more" item rendered with the legend-more class,
// and items past the fit stay rendered out of the flow, so both can always be
// measured.
export class LegendFitController {
// Items to show while collapsed, undefined until the list is measured
public fit?: number;
@@ -118,7 +125,7 @@ export class LegendFitController {
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
MAX_ROWS
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
);
if (fit !== this.fit) {
this.fit = fit;
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
* Gets the `popstate` or `location-changed` event, or nothing when the host
* is connected again.
*/
navigated: () => void;
navigated: (ev?: Event) => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
@@ -159,7 +161,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
this._options.navigated(ev);
}
};
+22
View File
@@ -20,6 +20,11 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -157,3 +162,20 @@ export const findEntities = (
return results;
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return findEntities(Object.keys(sources.states), filterFuncs);
};
+2 -2
View File
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
@@ -129,7 +129,7 @@ export const loadStyle = async (
).buildMapStyle(
mapStyle.palette,
mapStyle.options ?? {},
mapStyle.baseColors
mapStyle.styleOptions
);
if (typeof style.sprite === "string") {
+4 -8
View File
@@ -35,7 +35,7 @@ const styleUrls = () => {
export const buildMapStyle = async (
palette: MapPalette,
options: Record<string, unknown>,
baseColors?: Record<string, string>
styleOptions?: Record<string, unknown>
): Promise<StyleSpecification> => {
const [{ osm }, resolvedUrls] = await Promise.all([
import("@versatiles/style"),
@@ -50,13 +50,9 @@ export const buildMapStyle = async (
// The builder throws on an option it does not know and on a color it cannot
// parse. Those come from a card config or a theme, so one typo would
// otherwise drop the whole card to raster tiles. Step back to the style as it
// ships - colors included, or the default would come out as the builder's
// unpainted cartography - and only then to the bare palette.
const attempts = [
options,
...(baseColors ? [{ colors: baseColors }] : []),
{},
];
// comes - colors and adjustments included, or the default would come out as
// the builder's unpainted cartography - and only then to the bare palette.
const attempts = [options, ...(styleOptions ? [styleOptions] : []), {}];
let style: StyleSpecification | undefined;
for (const attempt of attempts) {
try {
+74 -16
View File
@@ -1,11 +1,12 @@
// The vector cartographies the map can draw. Each is a light and a dark
// palette; the theme mode picks the half.
// palette; the theme mode picks the half, unless the style only has one.
//
// Only the default pair is generated at build time (build-scripts/gulp/
// map-assets.js); everything else is built in the browser from the same
// builder, against the tiles and glyphs core's proxy already serves.
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
import { NEON_MAP_COLORS_DARK, NEON_MAP_OPTIONS } from "./neon-map-palette";
/** Style ids, in the order the card editor offers them */
export const MAP_STYLES = [
@@ -15,6 +16,7 @@ export const MAP_STYLES = [
"muted",
"gray",
"toner",
"neon",
] as const;
export type MapStyle = (typeof MAP_STYLES)[number];
@@ -26,13 +28,19 @@ type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
/** A cartography plus the colors painted over it; only the default has any */
/**
* A cartography plus what is painted over it; the VersaTiles palettes have
* nothing. The color sets say which halves a style has: both, or neither, and
* the theme mode picks; only one, and that is the half the map draws.
*/
const STYLE_PALETTES: Record<
MapStyle,
{
palette: VersatilesPalette;
colors?: Record<string, string>;
colorsDark?: Record<string, string>;
/** Builder options the style comes with, by option group */
options?: Record<string, Record<string, unknown>>;
}
> = {
default: {
@@ -40,6 +48,11 @@ const STYLE_PALETTES: Record<
colors: HA_MAP_COLORS,
colorsDark: HA_MAP_COLORS_DARK,
},
neon: {
palette: "colorful",
colorsDark: NEON_MAP_COLORS_DARK,
options: NEON_MAP_OPTIONS,
},
colorful: { palette: "colorful" },
natural: { palette: "natural" },
muted: { palette: "muted" },
@@ -96,11 +109,12 @@ export interface ResolvedMapStyle {
/** Fetched rather than built: the default, while nothing was added to it */
shipped?: "light" | "dark";
/**
* The style's own colors, without what the theme or the card painted over
* them. What to fall back on when the builder rejects an adjustment, so a
* bad value costs the adjustments rather than the cartography.
* The style as it comes: its colors and its own adjustments, without what
* the theme or the card painted over them. What to fall back on when the
* builder rejects an adjustment, so a bad value costs the card's adjustments
* rather than the style.
*/
baseColors?: Record<string, string>;
styleOptions?: Record<string, unknown>;
}
const isStyle = (value: unknown): value is MapStyle =>
@@ -110,9 +124,31 @@ export const isCustomMapStyle = (
style: MapStyleConfig | undefined
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
const toStyle = (style: MapStyleConfig | undefined): MapStyle => {
const named = isCustomMapStyle(style) ? style.base : style;
return isStyle(named) ? named : DEFAULT_MAP_STYLE;
};
/**
* Whether the map comes out dark: the theme mode's answer, unless the style
* only has the one half. What the map's own chrome and the theme tokens have
* to follow, rather than the theme mode.
*/
export const isDarkMapStyle = (
style: MapStyleConfig | undefined,
darkMode: boolean
): boolean => {
const { colors, colorsDark } = STYLE_PALETTES[toStyle(style)];
if (Boolean(colors) !== Boolean(colorsDark)) {
return Boolean(colorsDark);
}
return darkMode;
};
/** The half to draw; `dark` is the map's, from isDarkMapStyle */
export const paletteFor = (style: MapStyle, dark: boolean): MapPalette => {
const { palette } = STYLE_PALETTES[style];
return darkMode ? `${palette}-dark` : palette;
return dark ? `${palette}-dark` : palette;
};
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
@@ -198,19 +234,33 @@ export const resolveMapStyle = (
themeColors?: Record<string, string>
): ResolvedMapStyle => {
const custom = isCustomMapStyle(style) ? style : undefined;
const named = custom ? custom.base : style;
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
const mapStyle = toStyle(style);
// A dark-only style picks the dark half of everything, not just the palette.
const dark = isDarkMapStyle(style, darkMode);
const palette = paletteFor(mapStyle, darkMode);
const options = custom ? builderOptions(custom, darkMode) : {};
const palette = paletteFor(mapStyle, dark);
const options = custom ? builderOptions(custom, dark) : {};
// Before the colors below are merged in, so it answers whether the card
// asked for anything of its own.
const cardAdjusted = Object.keys(options).length > 0;
// The style's own adjustments go under the card's, group by group, so a
// card that sets one recolor value keeps the style's others.
const {
colors,
colorsDark,
options: styleOptions,
} = STYLE_PALETTES[mapStyle];
for (const [group, values] of Object.entries(styleOptions ?? {})) {
options[group] = {
...values,
...(options[group] as Record<string, unknown> | undefined),
};
}
// Bottom up: the style's colors, what the theme repaints, then what the card
// names - so a card that sets one color keeps the rest of the look.
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
const styleColors = dark ? colorsDark : colors;
if (styleColors || themeColors) {
options.colors = {
...styleColors,
@@ -223,11 +273,19 @@ export const resolveMapStyle = (
return { palette };
}
const base = styleColors ? { baseColors: styleColors } : {};
const own = styleColors || styleOptions;
const base = own
? {
styleOptions: {
...styleOptions,
...(styleColors && { colors: styleColors }),
},
}
: {};
// Anything added on top of the shipped default has to be built here.
const untouched = !themeColors && !cardAdjusted;
return mapStyle === DEFAULT_MAP_STYLE && untouched
? { palette, options, ...base, shipped: darkMode ? "dark" : "light" }
? { palette, options, ...base, shipped: dark ? "dark" : "light" }
: { palette, options, ...base };
};
+13
View File
@@ -0,0 +1,13 @@
import { isAndroidDevice } from "../../util/is_android";
import { isIosDevice } from "../../util/is_ios";
export const navigationUrl = (latitude: number, longitude: number): string => {
const point = `${latitude},${longitude}`;
if (isIosDevice()) {
return `https://maps.apple.com/?daddr=${point}`;
}
if (isAndroidDevice()) {
return `geo:${point}?q=${point}`;
}
return `https://www.openstreetmap.org/directions?to=${encodeURIComponent(point)}#map=15/${latitude}/${longitude}`;
};
Loaded 100 of 443 files, more files were not shown because too many files have changed in this diff. Show more