Compare commits

...
107 Commits
Author SHA1 Message Date
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
349 changed files with 12500 additions and 3629 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.
+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
+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'
@@ -24,7 +24,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
+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
+6 -6
View File
@@ -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
@@ -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"
+6 -6
View File
@@ -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 }}
@@ -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.
+3
View File
@@ -284,6 +284,7 @@ module.exports.config = {
name: "demo" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
...module.exports.mapWorkerEntry(),
},
outputPath: outputPath(paths.demo_output_root, latestBuild),
@@ -291,6 +292,8 @@ module.exports.config = {
defineOverlay: {
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
__DEMO__: true,
// The embed sets the global to load static files from its own server.
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
},
isProdBuild,
latestBuild,
+5 -1
View File
@@ -232,7 +232,11 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
gulp.task(
"gen-pages-demo-dev",
+18 -8
View File
@@ -214,16 +214,18 @@ const createRspackConfig = ({
},
},
{
// MapLibre's worker loads ESM plugins through `import(url)`, and the
// page side builds a blob import from `new URL(url, import.meta.url)`
// for a cross-origin worker. Neither path is taken here: the RTL
// plugin is UMD and the worker is served from the page's origin.
// Rspack's stubs for these fully dynamic requests are exactly what
// should remain - rejecting, and free of syntax the legacy floor
// cannot parse - so the warnings about them are noise.
// MapLibre's worker loads ESM plugins through `import(url)`, which
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
// fully dynamic request is exactly what should remain - rejecting,
// and free of syntax the legacy floor cannot parse - so the warning
// about it is noise. The page side builds a blob import from
// `new URL(url, import.meta.url)` for a worker on another origin,
// as in the demo embed. That is a URL, not an asset to bundle, and
// the worker URL is absolute, so leave it as it is.
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
parser: {
exprContextCritical: false,
url: false,
},
},
{
@@ -246,7 +248,15 @@ const createRspackConfig = ({
},
],
},
],
// The demo embed reads its own URL at runtime to find its files.
// Keep import.meta.url instead of replacing it with the source path.
// Development builds wrap modules in eval(), which rejects import.meta.
latestBuild &&
isProdBuild && {
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
parser: { importMeta: false },
},
].filter(Boolean),
},
optimization: {
minimizer: [
+42 -49
View File
@@ -1,7 +1,7 @@
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 {
@@ -13,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,
@@ -52,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}>
${
@@ -92,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>
`
}
@@ -193,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;
}
@@ -214,8 +207,13 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
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);
}
@@ -270,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;
}
+7
View File
@@ -16,3 +16,10 @@
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
+25
View File
@@ -0,0 +1,25 @@
import "./public-path";
import "./workers";
// The provider must be defined first. Custom elements upgrade in the order
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
// 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.
import "../../../src/components/item/ha-list-item-button";
import "../../../src/components/ha-select";
import { fontStyles } from "../../../src/resources/roboto";
// Font faces only work in the document, not in the shadow root of the card.
// Defining Roboto does not change the page unless it already uses Roboto.
document.head.append(
Object.assign(document.createElement("style"), {
textContent: fontStyles.cssText,
})
);
// Custom cards use the helpers to create built-in cards. The dashboard panel
// sets the same global in the full app.
(window as any).loadCardHelpers = () =>
import("../../../src/panels/lovelace/custom-card-helpers");
+218
View File
@@ -0,0 +1,218 @@
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 { 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;
};
/**
* 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.
*
* 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;
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);
}
});
}
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;
}
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>`;
}
protected updated() {
// The card element only exists once hui-card has updated
this._card.value?.updateComplete.then(() => this._updateSize());
}
// The size the card gets in a section, set on the host so that the page can
// lay out cards in a grid of 12 columns, like a section
private _updateSize = () => {
const card = this._card.value;
if (!card) {
return;
}
const { rows, columns } = computeCardGridSize(card.getGridOptions());
this.hidden = card.hidden;
this.toggleAttribute("full-width", columns === "full");
this.toggleAttribute("fit-rows", typeof rows === "number");
this.style.setProperty(
"--column-size",
typeof columns === "number" ? String(columns) : null
);
this.style.setProperty(
"--row-size",
typeof rows === "number" ? String(rows) : null
);
};
static styles = [
embedThemeStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
--row-gap: var(--ha-section-grid-row-gap, 8px);
--row-height: var(--ha-section-grid-row-height, 56px);
display: block;
grid-row: span var(--row-size, 1);
grid-column: span min(var(--column-size, 12), 12);
}
: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(
--row-gap
)
);
}
/* 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;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
+293
View File
@@ -0,0 +1,293 @@
import { ContextProvider, provide } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
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 { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { provideHass } from "../../../src/fake_data/provide_hass";
import type { DemoArea } from "../stubs/area_registry";
import { toAreaRegistryEntry } from "../stubs/area_registry";
import { mockAuth } from "../stubs/auth";
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
import { mockLogbook } from "../stubs/logbook";
import { mockMediaPlayer } from "../stubs/media_player";
import { mockRecorder } from "../stubs/recorder";
import { mockSensor } from "../stubs/sensor";
import { mockExtractFromTarget } from "../stubs/target";
import { mockTemplate } from "../stubs/template";
import { mockTodo } from "../stubs/todo";
import { mockWeather } from "../stubs/weather";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
/** The data of a provider, as set by its script child. */
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
// "living_room" becomes "Living room"
const areaName = (areaId: string) => {
const name = areaId.replaceAll("_", " ");
return name.charAt(0).toUpperCase() + name.slice(1);
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@provide({ context: embedHassContext })
@property({ attribute: false })
public hass!: MockHomeAssistant;
/**
* 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`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
@property({ attribute: false }) public areas?: DemoArea[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
*/
@property({ attribute: false }) public calendars?: Record<
string,
CalendarEventApiData[]
>;
/** Energy preferences. Without them, energy is not set up. */
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
/** Light or dark mode of the cards. Without it, the browser setting. */
@property({ attribute: "color-scheme" }) public colorScheme?:
"light" | "dark";
private _reducedMotion = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
private _unsubMediaQueries: (() => void)[] = [];
private _scriptRead = false;
private _applyColorScheme() {
const dark = this.colorScheme
? this.colorScheme === "dark"
: this._prefersDark;
if (this.hass.themes.darkMode !== dark) {
this.hass.updateHass({
themes: { ...this.hass.themes, darkMode: dark },
selectedTheme: { ...this.hass.selectedTheme!, dark },
});
}
}
constructor() {
super();
// Set up in the constructor so that the contexts exist before the
// child cards connect and request them.
const hass = provideHass(this);
hass.updateTranslations(null);
hass.updateTranslations("lovelace");
mockIcons(hass);
mockAuth(hass);
mockHistory(hass);
mockRecorder(hass);
mockTemplate(hass);
mockSensor(hass);
mockMediaPlayer(hass);
mockTodo(hass);
mockWeather(hass);
mockLogbook(hass);
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "logbook"],
},
});
mockCalendarEvents(hass, (entityId, currentHass) => {
if (!(entityId in currentHass.states)) {
return null;
}
const calendarIds = Object.keys(currentHass.states).filter((id) =>
id.startsWith("calendar.")
);
return (
this.calendars?.[entityId] ??
SAMPLE_CALENDAR_EVENTS[
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
]
);
});
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
// The registry of the entities, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
...newEntry({
entity_id: entity.entity_id,
platform: entity.platform ?? "demo",
unique_id: entity.entity_id,
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
registryEntries(currentHass)
);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => {
const entry = registryEntries(currentHass).find(
(item) => item.entity_id === msg.entity_id
);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
mockExtractFromTarget(hass);
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
...EMPTY_PREFERENCES,
...this.energy,
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
this._applyColorScheme();
}
public connectedCallback() {
super.connectedCallback();
this._unsubMediaQueries = [
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
this._prefersDark = matches;
this._applyColorScheme();
}),
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
this._reducedMotion.setValue(matches)
),
];
// Only once, so that moving the provider keeps the current entities
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptData(this) as
EntityInput[] | EmbedProviderData | undefined;
if (Array.isArray(data)) {
this.entities = data;
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.calendars = data.calendars;
this.energy = data.energy;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQueries.forEach((unsub) => unsub());
this._unsubMediaQueries = [];
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("colorScheme")) {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
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 });
// Entities that services changed keep their state when only the areas
// change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
this.hass.updateFormatFunctions();
}
}
}
protected updated(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass");
// The first update holds the initial entities. They are not changes.
if (!oldHass) {
return;
}
// Other updates, such as loaded translations, keep the same states object.
if (oldHass.states !== this.hass.states) {
fireEvent(this, "states-changed", { states: this.hass.states });
}
}
protected render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-provider": HaEmbedProvider;
}
interface HASSDomEvents {
"states-changed": { states: HassEntities };
}
}
+20
View File
@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
var __HA_STATIC_PATH__: string | undefined;
}
declare let __webpack_public_path__: string;
/* eslint-enable @typescript-eslint/naming-convention */
// Load chunks and static files from the server that hosts this script, so
// that other sites can embed the cards. Only the modern production build keeps
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
// builds only run on the demo site, where the default paths are correct.
// `new URL(path, import.meta.url)` in one expression is an asset reference for
// the bundler, so the script URL goes into a variable first.
if (__BUILD__ === "modern" && !__DEV__) {
const scriptUrl = import.meta.url;
__webpack_public_path__ = new URL(".", scriptUrl).href;
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
}
export {};
+17
View File
@@ -0,0 +1,17 @@
import { load } from "js-yaml";
/**
* Parses the data in a `<script type="application/json">` or
* `<script type="text/yaml">` child of the element.
*/
export const readScriptData = (element: HTMLElement): unknown => {
const script = element.querySelector<HTMLScriptElement>(
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
);
if (!script) {
return undefined;
}
return script.type === "text/yaml"
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
+61
View File
@@ -0,0 +1,61 @@
import { 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";
import { themeStyles } from "../../../src/resources/theme/theme";
// The theme styles set variables on `html`, next to fonts and other page
// styles. Only the variables, media queries, and keyframes are copied, and
// `html` becomes `selector`. This keeps the theme inside the card, so that it
// does not change the page that embeds the card.
const scopeThemeStyles = (cssText: string, selector: string): string => {
// A style element in a separate document parses the CSS without applying it.
const doc = document.implementation.createHTMLDocument("");
const style = doc.createElement("style");
style.textContent = cssText;
doc.head.append(style);
const convert = (rules: CSSRuleList): string =>
Array.from(rules, (rule) => {
if (rule instanceof CSSStyleRule) {
if (rule.selectorText !== "html") {
return "";
}
const variables = Array.from(rule.style)
.filter((name) => name.startsWith("--"))
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
return `${selector}{${variables.join("")}}`;
}
if (rule instanceof CSSMediaRule) {
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
}
if (rule instanceof CSSKeyframesRule) {
return rule.cssText;
}
return "";
}).join("");
return convert(style.sheet!.cssRules);
};
const darkTheme = {
...darkSemanticVariables,
...darkColorVariables,
};
// Like processTheme in apply_themes_on_element, each hex color also sets its
// `--rgb-` variable, else those keep their light theme values
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
const variable = `--${name}:${value};`;
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
return [variable];
}
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
});
export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
+32
View File
@@ -0,0 +1,32 @@
// Browsers only start a worker from a script on the page's own origin, and
// the workers of the cards, such as the markdown worker, are served next to
// this script. A worker from another origin starts from a blob instead, which
// loads the real worker script. The demo serves its scripts with CORS.
const pageWorker = globalThis.Worker;
globalThis.Worker = class extends pageWorker {
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
const url = new URL(scriptUrl, location.href);
if (url.origin === location.origin) {
super(scriptUrl, options);
return;
}
const source =
options?.type === "module"
? `import ${JSON.stringify(url.href)};`
: `importScripts(${JSON.stringify(url.href)});`;
const blobUrl = URL.createObjectURL(
new Blob([source], { type: "text/javascript" })
);
try {
super(blobUrl, options);
} finally {
URL.revokeObjectURL(blobUrl);
}
}
};
export {};
+5
View File
@@ -36,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockLogbook } from "./stubs/logbook";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -50,6 +51,7 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import { mockWeather } from "./stubs/weather";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -75,6 +77,7 @@ const CONFIG_PANEL_COMMANDS = [
"get_conditions_for_target",
"get_services_for_target",
"extract_from_target",
"explain_target",
"script/config",
"config/automation/config",
"config/script/config",
@@ -190,7 +193,9 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant card components</title>
<%= renderTemplate("../../../src/html/_header.html.template") %>
<meta name="referrer" content="same-origin" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<style>
:root {
color-scheme: light;
--page-background: #fafafa;
--page-text: #212121;
--page-code-background: #e5e5e5;
--page-divider: #dddddd;
}
@media (prefers-color-scheme: dark) {
:root:not([data-scheme="light"]) {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
}
:root[data-scheme="dark"] {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
html {
background-color: var(--page-background);
color: var(--page-text);
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
margin: 0;
padding: 0 16px 64px;
}
main {
max-width: 880px;
margin: 0 auto;
}
h1 {
margin: 32px 0 8px;
}
h2 {
margin: 48px 0 8px;
}
pre {
background: var(--page-code-background);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
font-size: 13px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: start;
vertical-align: top;
padding: 8px;
border-bottom: 1px solid var(--page-divider);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
align-items: start;
margin: 16px 0;
}
.single {
max-width: 400px;
margin: 16px 0;
}
.log {
height: 160px;
overflow-y: auto;
margin: 0;
}
button {
font: inherit;
padding: 6px 12px;
margin-inline-end: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<h1>Home Assistant card components</h1>
<p>
Embed working Home Assistant dashboard cards in any page. The cards run
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<ul>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
</li>
</ul>
<p>
All cards in the same provider share their entities. When you change
an entity in one card, the other cards show the change.
</p>
<p>
Color scheme of this page and its cards:
<button data-scheme="">Auto</button>
<button data-scheme="light">Light</button>
<button data-scheme="dark">Dark</button>
</p>
<h2>Use on your site</h2>
<p>
Load the script from the demo site. The script loads all other files
from the demo site too.
</p>
<pre><code>&lt;script type="module" src="https://demo.home-assistant.io/embed.js"&gt;&lt;/script&gt;</code></pre>
<h2>One card</h2>
<p>
Put the entities in a script tag inside the provider. Put the card
configuration in a script tag inside the card. Both take JSON with
<code>type="application/json"</code> or YAML with
<code>type="text/yaml"</code>. The card configuration is the same as in
a dashboard. YAML needs its indentation. If your site minifies HTML,
make sure the minifier keeps the whitespace in script tags, or use
JSON.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
&lt;/script&gt;
&lt;/ha-embed-card&gt;
&lt;/ha-embed-provider&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<p>
The <code>entities</code> attribute of the provider and the
<code>config</code> attribute of the card also take JSON.
</p>
<h2>Cards with shared state</h2>
<p>
These three cards are in one provider. Turn on a light in one card. The
other cards update. The log below shows the events of the provider.
</p>
<ha-embed-provider id="shared">
<script type="application/json">
[
{
"entity_id": "light.living_room",
"state": "off",
"attributes": {
"friendly_name": "Living room",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 255
}
},
{
"entity_id": "switch.coffee_maker",
"state": "on",
"attributes": {
"friendly_name": "Coffee maker",
"device_class": "outlet"
}
},
{
"entity_id": "climate.hallway",
"state": "heat",
"attributes": {
"friendly_name": "Hallway",
"current_temperature": 19.5,
"temperature": 21,
"min_temp": 7,
"max_temp": 30,
"target_temp_step": 0.5,
"hvac_modes": ["heat", "off"],
"hvac_action": "heating",
"supported_features": 1
}
},
{
"entity_id": "sensor.outside_temperature",
"state": "12.3",
"attributes": {
"friendly_name": "Outside temperature",
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°C"
}
}
]
</script>
<div class="cards">
<ha-embed-card>
<script type="application/json">
{
"type": "tile",
"entity": "light.living_room",
"tap_action": { "action": "toggle" }
}
</script>
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: entities
title: Living room
entities:
- light.living_room
- switch.coffee_maker
- sensor.outside_temperature
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<pre class="log" id="log"><code>Use the cards to see events here.</code></pre>
<h2>Cards outside a provider</h2>
<p>
A card does not have to be inside a provider. A card outside a provider
uses the first provider on the page. Put one provider with all entities
at the top of the page, and put the cards anywhere below it. If the
page has no provider, the card adds an empty provider to the page.
</p>
<p>
To use a different provider, set the <code>provider</code> attribute of
the card to the ID of the provider.
</p>
<pre><code>&lt;ha-embed-provider id="living-room"&gt;…&lt;/ha-embed-provider&gt;
&lt;p&gt;Some text about the coffee maker.&lt;/p&gt;
&lt;ha-embed-card provider="living-room"&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: switch.coffee_maker
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<p>
These two cards are not inside a provider. The first card uses the
first provider on this page, the kitchen light at the top. The second
card uses the provider with the ID <code>shared</code> above.
</p>
<div class="cards">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<h2>Light and dark mode</h2>
<p>
The cards follow the light or dark mode of the browser. To choose the
mode, set the <code>color-scheme</code> attribute of the provider to
<code>light</code> or <code>dark</code>. Use this when your site has its
own switch for dark mode. The buttons at the top of this page do this.
The cards keep their styles inside. They do not change the styles of
your page.
</p>
<pre><code>&lt;ha-embed-provider color-scheme="dark"&gt;…&lt;/ha-embed-provider&gt;</code></pre>
<h2>Change entities from the page</h2>
<p>
Set the <code>entities</code> property to change entities from
JavaScript. The new list replaces all entities. To change one entity,
start from the current states.
</p>
<p>
<button id="set-temperature">Set a random outside temperature</button>
<button id="toggle-coffee">Toggle the coffee maker</button>
</p>
<pre><code>const provider = document.querySelector("ha-embed-provider");
provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
entity.entity_id === "sensor.outside_temperature"
? { ...entity, state: "18.1" }
: entity
);</code></pre>
<p>
To use the actions of an entity, call a service. The provider updates
the state and the cards.
</p>
<pre><code>provider.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});</code></pre>
<h2>Listen to changes</h2>
<p>
The provider fires a <code>states-changed</code> event when the state
of one or more entities changes. The event has the states of all
entities. The event bubbles.
</p>
<pre><code>provider.addEventListener("states-changed", (ev) =&gt; {
const { states } = ev.detail;
console.log(states["light.living_room"].state);
});</code></pre>
<p>
Read the current state of all entities with
<code>provider.hass.states</code>.
</p>
<h2>Areas, 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
list in <code>entities</code>, and any of these keys:
</p>
<ul>
<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.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
ID. Each event has a <code>summary</code>, a <code>start</code> and
an <code>end</code>. Calendars without events here get sample events
around today.
</li>
<li>
<code>energy</code>: the energy settings, the same as the
<code>energy/get_prefs</code> response. Without it, energy is not
set up.
</li>
</ul>
<p>
Weather entities get a forecast made up from their condition and
temperature, or their <code>forecast</code> attribute when they have
one. Set <code>supported_features</code> to show forecasts.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
entities:
- entity_id: calendar.family
state: "off"
attributes:
friendly_name: Family
areas:
- area_id: kitchen
name: Kitchen
calendars:
calendar.family:
- summary: Dentist
start: "2026-11-02T15:30:00+00:00"
end: "2026-11-02T16:15:00+00:00"
&lt;/script&gt;
…
&lt;/ha-embed-provider&gt;</code></pre>
<p>This calendar card uses the sample events.</p>
<ha-embed-provider>
<script type="application/json">
{
"entities": [
{
"entity_id": "calendar.family",
"state": "off",
"attributes": { "friendly_name": "Family" }
}
]
}
</script>
<div class="single">
<ha-embed-card
config='{ "type": "calendar", "entities": ["calendar.family"], "initial_view": "listWeek" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<h2>Custom cards</h2>
<p>
Custom cards work too. Load the script of the custom card on the page,
and use <code>custom:</code> and the element name as the card type.
The custom card gets the same mocked backend as the other cards.
<code>window.loadCardHelpers()</code> is available to create built-in
cards.
</p>
<p>
The mocked backend only knows a part of the Home Assistant API. History
and statistics are made up from the current state of each entity.
Templates are not rendered, they come back as they are. The logbook is
empty. Other requests get an error. From another site, the script of
the custom card needs CORS headers, like the embed script.
</p>
<pre><code>&lt;script type="module" src="https://example.com/embed-demo-custom-card.js"&gt;&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: custom:embed-demo-custom-card
entity: light.office
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<ha-embed-provider>
<script type="application/json">
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {
"friendly_name": "Office",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 120
}
}
]
</script>
<div class="single">
<ha-embed-card
config='{ "type": "custom:embed-demo-custom-card", "entity": "light.office" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<p>This is the code of the custom card:</p>
<pre><code>class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
&lt;style&gt;
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
&lt;/style&gt;
&lt;ha-card&gt;
&lt;span class="badge"&gt;Custom card&lt;/span&gt;
&lt;p&gt;
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
&lt;/p&gt;
&lt;div class="tile"&gt;&lt;/div&gt;
&lt;button&gt;Toggle from the custom card&lt;/button&gt;
&lt;/ha-card&gt;`;
this.shadowRoot.querySelector("button").addEventListener("click", () =&gt;
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pre>
<script type="module">
class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
<style>
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
</style>
<ha-card>
<span class="badge">Custom card</span>
<p>
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
</p>
<div class="tile"></div>
<button>Toggle from the custom card</button>
</ha-card>`;
this.shadowRoot.querySelector("button").addEventListener("click", () =>
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);
</script>
<h2>Reference</h2>
<h3><code>&lt;ha-embed-provider&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>entities</code></td>
<td>Attribute and property</td>
<td>
A list of entities. Each entity has an <code>entity_id</code>, a
<code>state</code>, and <code>attributes</code>. The optional keys
<code>area_id</code>, <code>device_id</code>, and
<code>platform</code> set the entity registry entry. A new list
replaces all entities. A JSON or YAML script child sets the first
list.
</td>
</tr>
<tr>
<td><code>color-scheme</code></td>
<td>Attribute and property (<code>colorScheme</code>)</td>
<td>
<code>light</code> or <code>dark</code>. Without it, the cards
follow the browser setting.
</td>
</tr>
<tr>
<td><code>hass</code></td>
<td>Property, read-only</td>
<td>The mocked Home Assistant object that the cards use.</td>
</tr>
<tr>
<td><code>states-changed</code></td>
<td>Event</td>
<td>
Fires when the state of one or more entities changes. The detail
has <code>states</code>, the states of all entities by entity ID.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-card&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 card configuration, the same as in a dashboard. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>provider</code></td>
<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.
</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.
</p>
</main>
<script>
(function () {
var shared = document.getElementById("shared");
var log = document.getElementById("log");
var first = true;
shared.addEventListener("states-changed", function (ev) {
var states = ev.detail.states;
var line =
new Date().toLocaleTimeString() +
" " +
Object.keys(states)
.map(function (entityId) {
return entityId + "=" + states[entityId].state;
})
.join(", ");
if (first) {
log.textContent = "";
first = false;
}
log.textContent = line + "\n" + log.textContent;
});
document
.getElementById("set-temperature")
.addEventListener("click", function () {
var states = shared.hass.states;
shared.entities = Object.keys(states).map(function (entityId) {
var entity = states[entityId];
return {
entity_id: entityId,
state:
entityId === "sensor.outside_temperature"
? (Math.random() * 30 - 5).toFixed(1)
: entity.state,
attributes: entity.attributes,
};
});
});
document
.querySelectorAll("button[data-scheme]")
.forEach(function (button) {
button.addEventListener("click", function () {
var scheme = button.getAttribute("data-scheme");
if (scheme) {
document.documentElement.setAttribute("data-scheme", scheme);
} else {
document.documentElement.removeAttribute("data-scheme");
}
document
.querySelectorAll("ha-embed-provider")
.forEach(function (provider) {
if (scheme) {
provider.setAttribute("color-scheme", scheme);
} else {
provider.removeAttribute("color-scheme");
}
});
});
});
document
.getElementById("toggle-coffee")
.addEventListener("click", function () {
shared.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});
});
})();
</script>
<%= renderTemplate("../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../src/html/_script_loader.html.template") %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
import "<%= latestEntryJS[0] %>";
+15 -13
View File
@@ -12,6 +12,20 @@ export interface DemoArea {
let areas: AreaRegistryEntry[] = [];
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
});
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
@@ -25,19 +39,7 @@ export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
areas = demoAreas.map(toAreaRegistryEntry);
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+10 -120
View File
@@ -2,14 +2,14 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
import { explainTarget } from "./explain_target";
import { expandTarget, mockExtractFromTarget } from "./target";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
@@ -20,109 +20,6 @@ interface EntityFilter {
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
@@ -228,27 +125,20 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
mockExtractFromTarget(hass);
hass.mockWS(
"extract_from_target",
"explain_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
) =>
explainTarget(
msg.target,
(target, primaryEntitiesOnly) =>
expandTarget(currentHass, target, primaryEntitiesOnly),
Object.keys(currentHass.states),
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
)
);
hass.mockWS(
"get_services_for_target",
+122
View File
@@ -0,0 +1,122 @@
import type { CalendarEventApiData } from "../../../src/data/calendar";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample events relative to today, so demos always show something in the
// current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
export const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
export const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const toTime = (value: CalendarEventApiData["start"]) =>
new Date(
typeof value === "string"
? value
: "dateTime" in value
? value.dateTime
: value.date
).getTime();
const inRange = (
events: CalendarEventApiData[],
start: string | null,
end: string | null
): CalendarEventApiData[] => {
const startTime = start ? new Date(start).getTime() : -Infinity;
const endTime = end ? new Date(end).getTime() : Infinity;
return events.filter(
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
);
};
/**
* Answers event requests of calendar entities with the events of
* `getEvents`. A calendar without events gets `null`, like a calendar whose
* integration fails to return them.
*/
export const mockCalendarEvents = (
hass: MockHomeAssistant,
getEvents: (
entityId: string,
currentHass: MockHomeAssistant
) => CalendarEventApiData[] | null | undefined
) => {
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
const events = getEvents(msg.entity_id, currentHass);
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
return () => undefined;
});
// Home Assistant before 2026.5 has no event subscription, so cards that
// support it ask the REST API instead.
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
const url = new URL(path, location.origin);
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
const events = getEvents(entityId, currentHass);
if (!events) {
return Promise.reject(new Error("Calendar not found"));
}
return inRange(
events,
url.searchParams.get("start"),
url.searchParams.get("end")
);
});
};
@@ -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") },
],
+20 -20
View File
@@ -11,7 +11,7 @@ export const energyEntities = () =>
entity_id: "sensor.solar_production",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Solar",
unit_of_measurement: "kWh",
},
@@ -20,7 +20,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_input",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Battery Input",
unit_of_measurement: "kWh",
},
@@ -29,7 +29,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_output",
state: "3",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Battery Output",
unit_of_measurement: "kWh",
},
@@ -38,7 +38,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Grid consumption low tariff",
unit_of_measurement: "kWh",
},
@@ -47,7 +47,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Grid consumption high tariff",
unit_of_measurement: "kWh",
},
@@ -56,7 +56,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Returned to grid low tariff",
unit_of_measurement: "kWh",
},
@@ -65,7 +65,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Returned to grid high tariff",
unit_of_measurement: "kWh",
},
@@ -74,7 +74,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -82,7 +82,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -90,7 +90,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1_compensation",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -98,7 +98,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2_compensation",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -138,7 +138,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
unit_of_measurement: "EUR",
},
},
@@ -146,7 +146,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
@@ -155,7 +155,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Water",
unit_of_measurement: "L",
},
@@ -164,7 +164,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_car",
state: "4",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Electric car",
unit_of_measurement: "kWh",
},
@@ -173,7 +173,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_ac",
state: "3",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Air conditioning",
unit_of_measurement: "kWh",
},
@@ -182,7 +182,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_washing_machine",
state: "6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Washing machine",
unit_of_measurement: "kWh",
},
@@ -191,7 +191,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_dryer",
state: "5.5",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Dryer",
unit_of_measurement: "kWh",
},
@@ -200,7 +200,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_heat_pump",
state: "6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Heat pump",
unit_of_measurement: "kWh",
},
@@ -209,7 +209,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_boiler",
state: "7",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
state_class: "total_increasing",
friendly_name: "Boiler",
unit_of_measurement: "kWh",
},
+14 -2
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
const newEntry = (
export const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
@@ -43,7 +43,9 @@ const displayFields = (entry: EntityRegistryEntry) => ({
hidden: entry.hidden_by !== null,
});
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
export const extEntry = (
entry: EntityRegistryEntry
): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
@@ -56,6 +58,16 @@ export const mockEntityRegistry = (
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
const entry = entries.find((e) => e.entity_id === msg.entity_id);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
+79
View File
@@ -0,0 +1,79 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type {
TargetExplanation,
HassServiceTargetWithExclude,
TargetItem,
TargetType,
} from "../../../src/data/target";
const TYPES: TargetType[] = ["entity", "device", "area", "floor", "label"];
const items = (target?: HassServiceTarget): TargetItem[] =>
TYPES.flatMap((type) => {
const ids = target?.[`${type}_id`];
return ids ? ensureArray(ids).map((id) => ({ type, id: String(id) })) : [];
});
/** What one target item resolves to, and the areas and devices it reaches. */
export interface ResolvedTargetItem {
areas: string[];
devices: string[];
entities: string[];
}
/**
* Like core's explain_target: resolves each item of the target and of its
* `exclude` on its own with `resolve`, and tells what each exclude takes out
* of each included item. Like core, `entity_id: all` is every entity in
* `entityIds`, and excludes resolve every entity, not only the primary ones.
*/
export const explainTarget = (
target: HassServiceTargetWithExclude,
resolve: (
target: HassServiceTarget,
primaryEntitiesOnly: boolean
) => ResolvedTargetItem,
entityIds: string[],
primaryEntitiesOnly = true
): TargetExplanation => {
const single = ({ type, id }: TargetItem): HassServiceTarget => ({
[`${type}_id`]: [id],
});
const includes = items(target).map((item) => {
if (item.type === "entity" && item.id === "all") {
return {
...item,
entities: entityIds,
referenced_areas: [],
referenced_devices: [],
};
}
const { areas, devices, entities } = resolve(
single(item),
primaryEntitiesOnly
);
return {
...item,
entities,
referenced_areas: areas,
referenced_devices: devices,
};
});
return {
includes,
excludes: items(target.exclude).map((item) => {
const excluded = new Set(resolve(single(item), false).entities);
return {
...item,
removed: includes
.map(({ type, id, entities }) => ({
type,
id,
entities: entities.filter((entityId) => excluded.has(entityId)),
}))
.filter((include) => include.entities.length > 0),
};
}),
};
};
+15
View File
@@ -0,0 +1,15 @@
import type { LogbookStreamMessage } from "../../../src/data/logbook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/**
* Answers logbook subscriptions with an empty logbook. No entries come from
* an automation or script trace.
*/
export const mockLogbook = (hass: MockHomeAssistant) => {
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] } satisfies LogbookStreamMessage);
return () => undefined;
});
hass.mockWS("trace/contexts", () => ({}));
};
+1
View File
@@ -149,6 +149,7 @@ const repositories: RepositoryBase[] = [
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
beta: false,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
+3 -1
View File
@@ -372,7 +372,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState && "last_reset" in entityState.attributes
entityState &&
(entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
? generateSumStatistics(
start,
end,
+132
View File
@@ -0,0 +1,132 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
// Expands a target to its areas, devices and entities, like core does
export const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
+133
View File
@@ -0,0 +1,133 @@
import type {
ForecastAttribute,
ModernForecastType,
} from "../../../src/data/weather";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600000;
const CONDITIONS = [
"partlycloudy",
"rainy",
"cloudy",
"sunny",
"pouring",
"partlycloudy",
"sunny",
];
// Daily changes around the current temperature, so the forecast looks real.
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
const PRECIPITATION = new Map([
["rainy", 2.4],
["pouring", 9.1],
]);
const forecastEntry = (
datetime: Date,
temperature: number,
condition: string
): ForecastAttribute => ({
datetime: datetime.toISOString(),
condition,
temperature: Math.round(temperature * 10) / 10,
precipitation: PRECIPITATION.get(condition) ?? 0,
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
});
const generateForecast = (
forecastType: ModernForecastType,
temperature: number,
condition: string
): ForecastAttribute[] => {
const conditions = [condition, ...CONDITIONS.slice(1)];
const now = new Date();
if (forecastType === "hourly") {
now.setMinutes(0, 0, 0);
return Array.from({ length: 24 }, (_, hour) => {
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
// Warmest mid-afternoon and coldest before dawn
const change =
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
return forecastEntry(
datetime,
temperature + change,
conditions[Math.floor(hour / 6)]
);
});
}
now.setHours(12, 0, 0, 0);
if (forecastType === "twice_daily") {
return conditions.flatMap((dayCondition, day) => {
const datetime = new Date(now.getTime() + day * 24 * HOUR);
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return [
{
...forecastEntry(datetime, dayTemperature, dayCondition),
is_daytime: true,
},
{
...forecastEntry(
new Date(datetime.getTime() + 12 * HOUR),
dayTemperature - 6,
dayCondition
),
is_daytime: false,
},
];
});
}
return conditions.map((dayCondition, day) => {
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return {
...forecastEntry(
new Date(now.getTime() + day * 24 * HOUR),
dayTemperature,
dayCondition
),
templow: Math.round((dayTemperature - 6) * 10) / 10,
};
});
};
/**
* Answers forecast subscriptions of weather entities. An entity with a
* `forecast` attribute gets that forecast. Other entities get a forecast made
* up from their condition and temperature.
*/
export const mockWeather = (hass: MockHomeAssistant) => {
hass.mockWS(
"weather/subscribe_forecast",
(
msg: { entity_id: string; forecast_type: ModernForecastType },
currentHass,
onChange
) => {
const stateObj = currentHass.states[msg.entity_id];
onChange?.({
type: msg.forecast_type,
forecast: stateObj
? (stateObj.attributes.forecast ??
generateForecast(
msg.forecast_type,
Number(stateObj.attributes.temperature ?? 15),
stateObj.state
))
: null,
});
return () => undefined;
}
);
};
+10 -77
View File
@@ -1,68 +1,14 @@
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../../../demo/src/stubs/calendar";
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
@@ -79,8 +25,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: at(0, 8),
end_time: at(0, 8, 30),
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
location: "",
description: "",
supported_features:
@@ -96,8 +42,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: at(0, 9),
end_time: at(0, 9, 15),
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
},
},
{
@@ -145,20 +91,7 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
@@ -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,
@@ -5,6 +5,7 @@ import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { explainTarget } from "../../../../demo/src/stubs/explain_target";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { ensureArray } from "../../../../src/common/array/ensure-array";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
@@ -225,6 +226,20 @@ class DemoHaTargetPickerExclusions
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
hass.mockWS("extract_from_target", extractFromTarget);
hass.mockWS("explain_target", ({ target }: { target: HassServiceTarget }) =>
explainTarget(
target,
(single) => {
const resolved = extractFromTarget({ target: single });
return {
areas: resolved.referenced_areas,
devices: resolved.referenced_devices,
entities: resolved.referenced_entities,
};
},
Object.keys(hass.states)
)
);
hass.mockWS("auth/sign_path", (params) => params);
}
+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: {
+24 -24
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.8",
"@babel/runtime": "8.0.10",
"@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.1",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.2",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.1",
"@formatjs/intl-pluralrules": "7.0.1",
"@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",
@@ -80,9 +80,9 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "7.0.1",
"@versatiles/style": "7.1.0",
"@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",
@@ -100,7 +100,7 @@
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
@@ -137,14 +137,14 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.9",
"@babel/core": "8.0.10",
"@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.0",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
@@ -153,7 +153,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@rspack/core": "2.3.0",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
@@ -173,11 +173,11 @@
"@vitest/coverage-v8": "5.0.5",
"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 +188,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,7 +214,7 @@
"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",
@@ -222,8 +222,8 @@
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]5.0",
"packageManager": "[email protected]6.1",
"volta": {
"node": "24.22.1"
"node": "24.22.2"
}
}
+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,
+864 -831
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,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

+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",
@@ -144,7 +144,8 @@ export class MediaProgressController implements ReactiveController {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
// matches(":state(dragging)") throws before Chromium 125.
if (slider.customStates.has("dragging")) {
return;
}
slider.value = this.progress ?? 0;
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
const URL_ATTRIBUTES = [
export const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
@@ -6,6 +6,7 @@ import {
} from "../../data/entity/entity_attributes";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation";
import type { WeatherEntity } from "../../data/weather";
import { getWeatherUnit } from "../../data/weather";
import type { HomeAssistant, ValuePart } from "../../types";
@@ -115,6 +116,24 @@ export const computeAttributeValueToParts = (
}
}
// Date only, which is timezone agnostic, so parse and format it as
// local time to keep the same calendar day
if (isDate(attributeValue)) {
const date = new Date(`${attributeValue}T00:00`);
if (checkValidDate(date)) {
return [
{
type: "value",
value: formatDate(
date,
{ ...locale, time_zone: TimeZone.local },
config
),
},
];
}
}
// Value was not a timestamp, so only do date formatting
const date = new Date(attributeValue);
if (checkValidDate(date)) {
+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);
};
+11 -6
View File
@@ -16,8 +16,8 @@ import {
// other one is built in the browser. The attribution comes from the TileJSON,
// deliberately: it follows whoever serves the tiles.
const SHIPPED_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
light: `${__STATIC_PATH__}map/light.json`,
dark: `${__STATIC_PATH__}map/dark.json`,
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
@@ -92,8 +92,12 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
// The origin that serves the frontend's files: the page, except for the demo
// embed on another site
const filesBase = () => new URL(__STATIC_PATH__, location.href);
// MapLibre rejects a relative sprite URL
const absoluteSprite = (url: string) => new URL(url, location.href).href;
const absoluteSprite = (url: string) => new URL(url, filesBase()).href;
// Shared only while in flight, or a style that failed once would never be
// fetched again. Text rather than a parsed style: MapLibre mutates what it
@@ -125,7 +129,7 @@ export const loadStyle = async (
).buildMapStyle(
mapStyle.palette,
mapStyle.options ?? {},
mapStyle.baseColors
mapStyle.styleOptions
);
if (typeof style.sprite === "string") {
@@ -141,14 +145,15 @@ export const loadStyle = async (
// Global to MapLibre; set once before the first map is created. Absolute,
// because on Cast the page is not served from the instance. The URL comes
// from the build: the worker is an entry of its own, next to the app.
// from the build: the worker is an entry of its own, next to the app. MapLibre
// starts a worker from another origin itself.
let workerUrlSet = false;
export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
if (workerUrlSet) {
return;
}
workerUrlSet = true;
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
setUrl(new URL(__MAPLIBRE_WORKER_URL__, filesBase()).href);
};
const createVectorLayer = async (
+5 -9
View File
@@ -16,7 +16,7 @@ import { finalizeMapStyle } from "./map-style-transforms";
*/
// Versioned: /static is served with a month of max-age, so a cached manifest
// could otherwise pair a new build with the sprite sheet of an old one.
const STYLE_URLS_PATH = `/static/map/urls.json?v=${__VERSION__}`;
const STYLE_URLS_PATH = `${__STATIC_PATH__}map/urls.json?v=${__VERSION__}`;
let urls: Promise<OsmOptions["urls"]> | undefined;
@@ -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 {
@@ -78,7 +78,7 @@ const POSITIONS: Record<
bottomright: "bottom-right",
};
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
const MAPLIBRE_CSS_URL = `${__STATIC_PATH__}map/maplibre-gl.css`;
// Fully hidden behind the globe, not MapLibre's faint ghost
const MARKER_OPTIONS = { opacityWhenCovered: 0 };
+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}`;
};
+64
View File
@@ -0,0 +1,64 @@
// The "neon" style: the VersaTiles "colorful" cartography repainted as a night
// map, glowing roads on near-black ground. A dark set and no light one, which
// is what makes the style dark whatever the theme mode.
export const NEON_MAP_COLORS_DARK = {
background: "#07021a",
land: "#07021a",
water: "#13215e",
labelWater: "#00e5ff",
glacier: "#1b2a6b",
natureWood: "#0d1f2e",
natureGrass: "#101a33",
naturePark: "#12203a",
natureLeisure: "#111a30",
natureAgriculture: "#0d1528",
natureWetland: "#0e2440",
natureSand: "#1a1230",
natureRock: "#140c28",
areaResidential: "#0c0524",
areaCommercial: "#140430",
areaIndustrial: "#10062a",
areaWaste: "#160a2e",
areaBurial: "#0d0a26",
siteParking: "#0f0728",
siteSports: "#0a1c2c",
siteEducation: "#160a34",
siteHospital: "#2a0a2a",
siteDanger: "#ff1744",
sitePrison: "#1c0a28",
siteConstruction: "#3a1d5c",
building: "#160a33",
buildingBg: "#8a2be2",
roadStreet: "#00e5ff",
roadStreetBg: "#062b52",
roadSecondary: "#8f7bff",
roadSecondaryBg: "#241058",
roadTrunk: "#ff6ec7",
roadTrunkBg: "#4a0f38",
roadMotorway: "#ff2d95",
roadMotorwayBg: "#5c0033",
transitRail: "#7b2cff",
transitSubway: "#b14cff",
transitCycle: "#00ffa3",
transitFoot: "#2e2a55",
boundary: "#39ff14",
boundaryDisputed: "#1f7a0d",
label: "#ff9ad5",
labelHalo: "rgba(7,2,26,0.9)",
labelSymbol: "#ff9ad5",
labelPoi: "#00e5ff",
labelShield: "#12002a",
labelHousenumber: "rgba(0,229,255,0.35)",
};
/** Builder options the style comes with, under whatever a card adds */
export const NEON_MAP_OPTIONS = {
recolor: { saturate: 0.35, contrast: 1.15 },
text: {
transform: "uppercase",
letterSpacing: 0.12,
haloWidth: 2,
haloBlur: 0,
},
};
@@ -1,14 +0,0 @@
import type {
ListItem,
RequestSelectedDetail,
} from "@material/mwc-list/mwc-list-item";
export const shouldHandleRequestSelectedEvent = (
ev: CustomEvent<RequestSelectedDetail>
): boolean => {
if (!ev.detail.selected || ev.detail.source !== "property") {
return false;
}
(ev.currentTarget as ListItem).selected = false;
return true;
};
+10 -1
View File
@@ -1,3 +1,4 @@
import { SYSTEM_LOG_LEVELS, type SystemLogLevel } from "../../data/system_log";
import {
decodeQueryParams,
hasQueryParams,
@@ -17,7 +18,7 @@ export const logSearchQueryParamConfig = {
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration"],
list: ["integration", "level"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -28,13 +29,19 @@ export interface LogsState {
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
// Only apply to the Home Assistant Core errors, not the raw log
levels: SystemLogLevel[];
raw: boolean;
}
const isLevel = (level: string): level is SystemLogLevel =>
(SYSTEM_LOG_LEVELS as readonly string[]).includes(level);
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
levels: [],
raw: false,
};
@@ -53,6 +60,7 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
@@ -71,6 +79,7 @@ export const logsLinkFromUrl = (
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
raw: core && !!params.raw,
};
};
@@ -1,4 +1,4 @@
import type { ContextType } from "@lit/context";
import { provide, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -17,9 +17,14 @@ import { getEntityTarget } from "../../panels/config/automation/target/get_entit
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
import { targetExpandGroupContext } from "../target-picker/target-expand-group-context";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
// Triggers and conditions don't expand the groups their targets name.
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@@ -67,3 +67,88 @@ export const itemTooltipPosition: TooltipPositionCallback = (
return [x, y];
};
const ECHARTS_TOOLTIP_GAP_PX = 20;
export const WINDOW_MARGIN_PX = 8;
const fitInto = (
pos: number,
size: number,
alternatives: number[],
min: number,
max: number
) =>
size > max - min
? min
: ([pos, ...alternatives].find((p) => p >= min && p + size <= max) ??
Math.max(min, Math.min(pos, max - size)));
/**
* ECharts' default placement (or the chart-confined one), moved into the
* window when it would leave it. The other side of the cursor is tried before
* clamping, below first vertically. A tooltip taller than the window starts at
* its top. A confined tooltip stays in the chart, leaving it only as far as the
* window needs.
*/
export const windowTooltipPosition =
(
getChartRect: () => DOMRect | undefined,
confine: boolean
): TooltipPositionCallback =>
(point, _params, _dom, _rect, size) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const right = cursorX + ECHARTS_TOOLTIP_GAP_PX;
const left = cursorX - ECHARTS_TOOLTIP_GAP_PX - tipW;
const below = cursorY + ECHARTS_TOOLTIP_GAP_PX;
const above = cursorY - ECHARTS_TOOLTIP_GAP_PX - tipH;
let x = right + tipW + 2 > viewW ? left : right;
let y = below + tipH > viewH ? above : below;
if (confine) {
x = Math.max(0, Math.min(x, viewW - tipW));
y = Math.max(0, Math.min(y, viewH - tipH));
}
const rect = getChartRect();
if (!rect?.height) {
return [x, y];
}
const fit = (
pos: number,
tipSize: number,
alternatives: number[],
windowMin: number,
windowMax: number,
viewSize: number
) => {
if (!confine) {
return fitInto(pos, tipSize, alternatives, windowMin, windowMax);
}
const min = Math.max(0, windowMin);
const max = Math.min(viewSize, windowMax);
return tipSize > max - min
? fitInto(pos, tipSize, [], windowMin, windowMax)
: fitInto(pos, tipSize, alternatives, min, max);
};
const { clientWidth, clientHeight } = document.documentElement;
return [
fit(
x,
tipW,
[right, left],
WINDOW_MARGIN_PX - rect.left,
clientWidth - WINDOW_MARGIN_PX - rect.left,
viewW
),
fit(
y,
tipH,
[below, above],
WINDOW_MARGIN_PX - rect.top,
clientHeight - WINDOW_MARGIN_PX - rect.top,
viewH
),
];
};
+15 -1
View File
@@ -68,6 +68,7 @@ import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { windowTooltipPosition } from "./chart-tooltip-position";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -1252,9 +1253,19 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
next.appendTo = undefined;
}
if (next.position === undefined) {
// ECharts confines after the position callback, which would undo the
// window fit, so the callback confines itself
next.position = windowTooltipPosition(
() => this._chartContainer?.getBoundingClientRect(),
isMobile || next.confine === true
);
next.confine = false;
} else if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
}
if (isMobile || this._touchInput) {
next.triggerOn = "click";
@@ -2312,6 +2323,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
--ha-assist-chip-trailing-space: var(--ha-space-2);
--ha-assist-chip-icon-label-space: var(--ha-space-1);
}
.zoom-reset {
margin-inline-end: var(--ha-space-3);
}
`;
}
+11 -5
View File
@@ -31,8 +31,14 @@ export interface NetworkNode {
color?: string;
borderColor?: string;
borderWidth?: number;
opacity?: number;
};
label?: {
position?: "left" | "right" | "top" | "bottom";
show?: boolean;
fontWeight?: "normal" | "bold";
fontSize?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -51,6 +57,7 @@ export interface NetworkLink {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
opacity?: number;
};
symbolSize?: number | number[];
symbol?: string;
@@ -286,9 +293,8 @@ export class HaNetworkGraph extends LitElement {
return node.name ?? "";
},
rich: {
primary: {
fontSize: 12,
},
// inherits the node label font, so a node can override it
primary: {},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
@@ -648,7 +654,7 @@ export class HaNetworkGraph extends LitElement {
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
margin-inline-end: var(--ha-space-3);
}
`;
}
+55 -42
View File
@@ -22,6 +22,8 @@ export interface Node {
color?: string;
passThrough?: boolean;
entityId?: string;
// Nodes of one group take a single position when ordering their children
group?: string;
}
export interface Link {
source: string;
@@ -46,6 +48,46 @@ const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
// Links without a value take the remaining capacity of their nodes, in order.
export const fillSankeyLinkValues = (
nodes: Node[],
rawLinks: Link[]
): ProcessedLink[] => {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
};
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@property({ attribute: false }) public data: SankeyChartData = {
@@ -190,7 +232,7 @@ export class HaSankeyChart extends LitElement {
);
}
});
const links = this._processLinks(filteredNodes, data.links);
const links = fillSankeyLinkValues(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
@@ -304,60 +346,31 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
const countedGroups = new Set<string>();
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
const positions = [...new Set(sections[i].map((n) => n.group ?? n.id))];
const node = sections[i].find((n) => n.id === parent);
if (node) {
if (node.group !== undefined) {
if (countedGroups.has(node.group)) {
break;
}
countedGroups.add(node.group);
}
sum += offset + positions.indexOf(node.group ?? node.id);
count++;
break;
}
offset += section.length;
offset += positions.length;
}
}
return count > 0 ? sum / count : -1;
+1
View File
@@ -352,6 +352,7 @@ export class HaChipBase extends HaButton {
}
.container {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
max-width: 100%;
@@ -12,11 +12,11 @@ import { haStyleDialog } from "../../resources/styles";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
import "../ha-sortable";
import "../ha-svg-icon";
import "../ha-dialog";
import "../item/ha-list-item-base";
import "../list/ha-list-base";
import type {
DataTableColumnContainer,
DataTableColumnData,
@@ -139,7 +139,7 @@ export class DialogDataTableSettings extends LitElement {
draggable-selector=".draggable"
handle-selector=".handle"
>
<ha-list>
<ha-list-base>
${repeat(
columns,
(col) => col.key,
@@ -152,23 +152,23 @@ export class DialogDataTableSettings extends LitElement {
col.defaultHidden)
: col.defaultHidden);
return html`<ha-list-item
hasMeta
return html`<ha-list-item-base
class=${classMap({
hidden: !isVisible,
draggable: canMove && isVisible,
})}
graphic="icon"
noninteractive
>${col.title || col.label || col.key}
>
<span slot="headline"
>${col.title || col.label || col.key}</span
>
${
canMove && isVisible
? html`<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
slot="graphic"
slot="start"
></ha-svg-icon>`
: nothing
: html`<span class="handle-spacer" slot="start"></span>`
}
<ha-icon-button
tabindex="0"
@@ -176,7 +176,7 @@ export class DialogDataTableSettings extends LitElement {
.disabled=${!canHide}
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
slot="end"
.label=${localize(
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
{ title: typeof col.title === "string" ? col.title : "" }
@@ -184,10 +184,10 @@ export class DialogDataTableSettings extends LitElement {
.column=${col.key}
@click=${this._toggle}
></ha-icon-button>
</ha-list-item>`;
</ha-list-item-base>`;
}
)}
</ha-list>
</ha-list-base>
</ha-sortable>
<ha-dialog-footer slot="footer">
<ha-button
@@ -326,8 +326,8 @@ export class DialogDataTableSettings extends LitElement {
--dialog-z-index: 10;
--dialog-content-padding: 0 8px;
}
ha-list-item {
--mdc-list-side-padding: 12px;
ha-list-item-base {
--ha-row-item-padding-inline: 12px;
overflow: visible;
}
.hidden {
@@ -345,6 +345,11 @@ export class DialogDataTableSettings extends LitElement {
display: block;
margin: -12px;
}
.handle-spacer {
display: block;
width: 24px;
height: 24px;
}
`,
];
}
@@ -161,7 +161,8 @@ export abstract class HaDeviceAutomationPicker<
return html`<ha-generic-picker
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.getItems=${this._getItems(
value,
this._automations,
+37 -1
View File
@@ -189,6 +189,10 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _loadFailed = false;
private _statisticIdsRequest = 0;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
@@ -201,11 +205,36 @@ export class HaStatisticPicker extends LitElement {
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
// Changing the statistic types starts a new request, and only the latest
// one may update the picker
const request = ++this._statisticIdsRequest;
this._loadFailed = false;
let statisticIds: StatisticsMetaData[];
try {
statisticIds = await getStatisticIds(this._api, this.statisticTypes);
} catch {
if (request === this._statisticIdsRequest) {
this._loadFailed = true;
}
return;
}
if (request !== this._statisticIdsRequest) {
return;
}
this.statisticIds = statisticIds;
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _retryGetStatisticIds(ev: Event) {
ev.stopPropagation();
this._getStatisticIds();
}
private _getItems = () =>
this._getStatisticsItems(
this._states,
@@ -589,6 +618,12 @@ export class HaStatisticPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.loading=${!this._loadFailed && this.statisticIds === undefined}
.loadError=${
this._loadFailed
? this._i18n.localize("ui.components.statistic-picker.load_failed")
: undefined
}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@@ -598,6 +633,7 @@ export class HaStatisticPicker extends LitElement {
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryGetStatisticIds}
>
</ha-generic-picker>
`;
+19 -5
View File
@@ -7,6 +7,7 @@ import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { computeCssColor } from "../../common/color/compute-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
@@ -21,8 +22,10 @@ import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import {
brandsAccessTokenContext,
connectionContext,
uiContext,
} from "../../data/context";
import { isBrandUrl } from "../../util/brands-url";
import type { HomeAssistantUI } from "../../types";
import { darkBrandUrl, isBrandUrl } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
@@ -56,6 +59,13 @@ export class StateBadge extends LitElement {
// @ts-ignore read in willUpdate through changedProps only
private _brandsAccessToken?: string;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: (ui) => ui?.themes?.darkMode,
})
private _darkMode?: boolean;
@state() private _iconStyle: Record<string, string | undefined> = {};
connectedCallback(): void {
@@ -141,7 +151,9 @@ export class StateBadge extends LitElement {
!changedProps.has("stateColor") &&
!changedProps.has("color") &&
!changedProps.has("_connection") &&
!changedProps.has("_brandsAccessToken")
!changedProps.has("_brandsAccessToken") &&
// Brand images have a dark variant, so the picture changes with the theme
!changedProps.has("_darkMode")
) {
return;
}
@@ -217,15 +229,17 @@ export class StateBadge extends LitElement {
// Sign the image URL via the connection context so brand images
// (/api/brands/...) get their access token. Without a way to sign, a brands
// request would be rejected (and logged/blocked by core), so skip it until
// we can sign.
// we can sign. In dark mode, brand images (e.g. the integration icon of an
// update entity) use their dark-optimized variant.
private _resolveImageUrl(url: string | undefined): string {
if (!url) {
return "";
}
const themedUrl = this._darkMode ? darkBrandUrl(url) : url;
if (this._connection) {
return this._connection.hassUrl(url);
return this._connection.hassUrl(themedUrl);
}
return isBrandUrl(url) ? "" : url;
return isBrandUrl(themedUrl) ? "" : themedUrl;
}
protected getClass() {
+4
View File
@@ -58,6 +58,10 @@ export class HaAnsiToHtml extends LitElement {
// The hidden lines each expand row shows when clicked.
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
public get hasLines(): boolean {
return !!this._pre?.childElementCount;
}
protected render(): TemplateResult {
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
}
+37 -9
View File
@@ -5,10 +5,12 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import type { LocalizeFunc } from "../common/translations/localize";
import type { FrontendLocaleData } from "../data/translation";
declare global {
@@ -43,6 +45,8 @@ export class HaControlSlider extends LitElement {
@property({ attribute: false }) public locale?: FrontendLocaleData;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@property({ type: Boolean, reflect: true })
public disabled = false;
@@ -139,12 +143,28 @@ export class HaControlSlider extends LitElement {
this.setupListeners();
}
protected updated(changedProps: PropertyValues<this>) {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("value") || changedProps.has("roundValue")) {
const valuenow = this._displayedValue(this.value ?? 0);
this.setAttribute("aria-valuenow", valuenow.toString());
this.setAttribute("aria-valuetext", this._formatValue(valuenow));
if (
changedProps.has("value") ||
changedProps.has("roundValue") ||
changedProps.has("_localize")
) {
// An unknown value must not be announced as the minimum.
if (this.value == null) {
this.removeAttribute("aria-valuenow");
} else {
this.setAttribute(
"aria-valuenow",
this._displayedValue(this.value).toString()
);
}
const valuetext = this._ariaValueText();
if (valuetext) {
this.setAttribute("aria-valuetext", valuetext);
} else {
this.removeAttribute("aria-valuetext");
}
}
if (changedProps.has("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
@@ -312,6 +332,13 @@ export class HaControlSlider extends LitElement {
return Math.max(Math.min(1, (x - offset) / total), 0);
};
private _ariaValueText() {
if (this.value == null) {
return this._localize?.("state.default.unknown");
}
return this._formatValue(this._displayedValue(this.value));
}
private _formatValue(value: number) {
const formattedValue = formatNumber(value, this.locale);
@@ -349,7 +376,8 @@ export class HaControlSlider extends LitElement {
}
protected render(): TemplateResult {
const valuenow = this._displayedValue(this.value ?? 0);
const valuenow =
this.value != null ? this._displayedValue(this.value) : undefined;
return html`
<div
class="container${classMap({
@@ -365,8 +393,8 @@ export class HaControlSlider extends LitElement {
role="slider"
tabindex="0"
aria-label=${ifDefined(this.label)}
aria-valuenow=${valuenow.toString()}
aria-valuetext=${this._formatValue(valuenow)}
aria-valuenow=${ifDefined(valuenow?.toString())}
aria-valuetext=${ifDefined(this._ariaValueText() || undefined)}
aria-valuemin=${ifDefined(
this.min != null ? this.min.toString() : undefined
)}
@@ -395,7 +423,7 @@ export class HaControlSlider extends LitElement {
class=${classMap({
"slider-track-bar": true,
[this.mode ?? "start"]: true,
"show-handle": this.showHandle,
"show-handle": this.showHandle && this.value != null,
})}
></div>
`
+55 -1
View File
@@ -1,10 +1,11 @@
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -25,6 +26,9 @@ import type { IntegrationManifest } from "../data/integration";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-badge";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -32,6 +36,14 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -40,6 +52,12 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -129,6 +147,7 @@ export class HaFilterIntegrations extends LitElement {
brand-fallback
></ha-domain-icon>
<span slot="headline">${integration.name}</span>
${this._renderCounts(integration.domain)}
</ha-list-item-option>`
)}
</ha-list-selectable>`
@@ -140,6 +159,32 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string): TemplateResult | typeof nothing {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span slot="supporting-text" class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -249,6 +294,15 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
margin-top: var(--ha-space-1);
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+161 -43
View File
@@ -2,7 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import { mdiPlaylistPlus, mdiRefresh } from "@mdi/js";
import {
css,
html,
@@ -17,13 +17,15 @@ import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
import { configContext, internationalizationContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-alert";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
@@ -33,6 +35,7 @@ import type {
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import "./ha-svg-icon";
import "./skeleton/ha-skeleton";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@@ -114,6 +117,15 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
/** Shows a skeleton in place of the field while its items load. */
@property({ type: Boolean, reflect: true }) public loading = false;
/**
* Shows this error with a retry button in place of the field when its items
* failed to load. The retry button fires `picker-retry`.
*/
@property({ attribute: false }) public loadError?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -124,6 +136,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// Set once loading starts, then hidden with CSS once it ends, so the
// skeleton fades out over the field
@state() private _skeletonShown = false;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -155,9 +175,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("loading") && this.loading) {
this._skeletonShown = true;
}
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
changedProperties.has("noUnknownState") ||
changedProperties.has("loading") ||
changedProperties.has("loadError")
) {
this._setUnknownValue();
}
@@ -183,45 +208,73 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: nothing
}
<div id="picker">
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
<div class="field" ?inert=${this.loading || !!this.loadError}>
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._skeletonShown
? html`<ha-skeleton
class="loading-skeleton"
role=${ifDefined(this.loading ? "progressbar" : undefined)}
aria-label=${ifDefined(
this.loading ? this._loadingLabel() : undefined
)}
></ha-skeleton>`
: nothing
}
${
this.loadError
? html`<div class="load-error">
<ha-alert alert-type="error">
${this.loadError}
<ha-icon-button
slot="action"
.path=${mdiRefresh}
.label=${this._i18n?.localize("ui.common.retry")}
@click=${this._retry}
></ha-icon-button>
</ha-alert>
</div>`
: nothing
}
</div>
${
this._pickerWrapperOpen || this._opened
@@ -298,9 +351,25 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _loadingLabel() {
const label = this.ariaLabel || this.label || this.placeholder;
return label
? this._i18n?.localize("ui.components.generic-picker.loading", {
label,
})
: this._i18n?.localize("ui.common.loading");
}
private _retry(ev: Event) {
ev.stopPropagation();
fireEvent(this, "picker-retry");
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.loading ||
this.loadError ||
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
@@ -413,7 +482,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
) {
ev?.stopPropagation();
if (this.disabled) {
if (this.disabled || this.loading || this.loadError) {
return;
}
this._selectedValue = options?.selectedValue;
@@ -459,6 +529,53 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block;
max-width: 100%;
}
/* The error shares the field's cell, so a wrapping error grows the
picker instead of covering what follows */
#picker {
position: relative;
display: grid;
}
#picker > .field,
#picker > .load-error {
grid-area: 1 / 1;
min-width: 0;
}
/* The field keeps its place under the skeleton, so nothing moves and
the two cross fade once loading ends */
.field {
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
:host([loading]) .field,
.field[inert] {
opacity: 0;
}
.loading-skeleton {
position: absolute;
inset: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-sm);
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
@starting-style {
.loading-skeleton {
opacity: 0;
}
}
/* Hidden once faded out, and at once when no fade runs */
:host(:not([loading])) .loading-skeleton {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease-out,
visibility 0s linear var(--ha-animation-duration-fast, 150ms);
}
.load-error {
display: flex;
align-items: center;
}
.load-error ha-alert {
flex: 1;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
@@ -540,5 +657,6 @@ declare global {
interface HASSDomEvents {
"picker-closed": undefined;
"picker-opened": undefined;
"picker-retry": undefined;
}
}
+2
View File
@@ -86,6 +86,7 @@ class HaMarkdownElement extends ReactiveElement {
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
rewriteUrl: Boolean(this.rewriteUrl),
});
}
@@ -99,6 +100,7 @@ class HaMarkdownElement extends ReactiveElement {
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
allowRelativeUrls: Boolean(this.rewriteUrl),
}
);
+2 -1
View File
@@ -178,7 +178,8 @@ export class HaNavigationPicker extends LitElement {
allow-custom-value
.placeholder=${this.label}
.helper=${this.helper}
.disabled=${this._loading || this.disabled}
.disabled=${this.disabled}
.loading=${this._loading}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
+88 -21
View File
@@ -15,6 +15,7 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { findSectionTitle } from "./list/find-section-title";
import { searchResultsStatus } from "./list/search-results-status";
import { consume } from "../common/decorators/consume";
import {
fireEvent,
@@ -23,6 +24,7 @@ import {
} from "../common/dom/fire_event";
import { ignoreRepeatedActivation } from "../common/keyboard/ignore-repeated-activation";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
@@ -164,6 +166,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
/** Whether a row from `getItems` counts as a search result for screen readers, so a row offered for any search, like the quick bar's "Ask Assist", can be left out */
@property({ attribute: false })
public isSearchResult?: (item: PickerComboBoxItem) => boolean;
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@@ -224,12 +230,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _sectionTitle?: string;
@state() private _status?: string;
private _allItems: PickerComboBoxItem[] = [];
// Rows from this index on are added by the combo box itself, like "Add custom
// item", and belong to no section.
private _sectionedItemsCount = 0;
// Rows the combo box offers for any search, like "Add custom item" and the
// additional items, are not search results.
private _actionItems = new WeakSet<PickerComboBoxItem>();
// The virtualized list renders its scroller a moment after it connects, so
// the scroll fades attach once it reports its first visible rows.
private _virtualScrollElement?: HTMLElement;
@@ -251,13 +263,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
public willUpdate() {
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._selectedSection = this.selectedSection;
this._allItems = this._getItems();
this._items = this._allItems;
this._updateListMode();
}
// New rows make the last message out of date, so clear it at once. The new
// one is then read out even when its text is the same as before.
if (changedProps.has("_items")) {
this._status = undefined;
}
}
protected updated(changedProps: PropertyValues) {
@@ -267,6 +284,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (changedProps.has("_items") && this._search) {
this._highlightTopMatch();
}
if (changedProps.has("_items")) {
if (this._search) {
this._updateStatus();
} else {
this._updateStatus.cancel();
}
}
}
// Scroll before the surface animates in, so the list does not jump at the
@@ -303,6 +327,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
disconnectedCallback() {
super.disconnectedCallback();
this._removeKeyboardShortcuts?.();
this._updateStatus.cancel();
this._status = undefined;
}
public refreshItems() {
@@ -362,9 +388,28 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
>
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
${this.renderScrollableFades()}
</div>
<div class="visually-hidden" role="status">
${this._status ?? nothing}
</div>`;
}
// DOM focus stays in the search field, so screen readers hear nothing when
// the list changes. Announce how many items match instead, once typing
// pauses, so the count doesn't compete with each typed character.
private _updateStatus = debounce(() => {
this._status = searchResultsStatus(
this._search,
this._items,
(item) =>
this._isOption(item) &&
!this._actionItems.has(item) &&
(this.isSearchResult?.(item) ?? true),
this.i18n?.localize,
this.notFoundLabel
);
}, 500);
private _renderPlainList() {
return html`
<ha-list-selectable
@@ -376,6 +421,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
virtual-focus
controlled
tabindex="0"
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
>
@@ -406,6 +452,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.value
)}
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@ha-list-visibility-changed=${this._visibilityChanged}
@@ -430,6 +477,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-id=${section.id}
.selected=${this._selectedSection === section.id}
.label=${section.label}
aria-label=${
this.i18n?.localize?.(
"ui.components.combo-box.filter_by_section",
{ section: section.label }
) || section.label
}
>
</ha-filter-chip>`
)}
@@ -454,8 +507,25 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
private _getAdditionalItems = (searchString?: string) => {
const items = this.getAdditionalItems?.(searchString) || [];
items.forEach((item) => this._actionItems.add(item));
return items;
};
private _getCustomValueItem(search: string): PickerComboBoxItem {
const item = {
id: search,
primary:
this.customValueLabel ??
this.i18n?.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${search}"`,
icon_path: mdiPlus,
};
this._actionItems.add(item);
return item;
}
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
@@ -497,15 +567,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
items.push(...additionalItems);
if (this.allowCustomValue && this._search) {
items.push({
id: this._search,
primary:
this.customValueLabel ??
this.i18n?.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${this._search}"`,
icon_path: mdiPlus,
});
items.push(this._getCustomValueItem(this._search));
}
if (this.mode === "dialog") {
@@ -666,15 +728,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
if (this.allowCustomValue && searchString) {
filteredItems.push({
id: searchString,
primary:
this.customValueLabel ??
this.i18n?.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,
});
filteredItems.push(this._getCustomValueItem(searchString));
}
this._items = filteredItems;
@@ -684,6 +738,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._resetListScroll();
};
// Keep focus in the search field, so a focus change doesn't scroll the
// list to the active row and make the click miss.
private _preventBlur(ev: Event) {
ev.preventDefault();
}
@@ -1018,6 +1074,17 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
align-items: center;
padding: var(--ha-space-3);
}
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
`,
];
}
@@ -162,7 +162,7 @@ export class HaObjectSelector extends LitElement {
description = this._formatField(item, descriptionField);
}
const reorderable = this.selector.object!.multiple || false;
const reorderable = this._reorderable;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-list-item-base class="item">
@@ -212,6 +212,7 @@ export class HaObjectSelector extends LitElement {
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
.disabled=${!this._reorderable}
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
@@ -291,11 +292,18 @@ export class HaObjectSelector extends LitElement {
}));
});
private get _reorderable(): boolean {
return (
(this.selector.object?.multiple ?? false) &&
this.selector.object?.reorder !== false
);
}
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this.selector.object!.multiple) {
if (!this._reorderable) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
+187 -1
View File
@@ -1,7 +1,14 @@
import TabGroup from "@home-assistant/webawesome/dist/components/tab-group/tab-group";
import { css, type CSSResultGroup } from "lit";
import { css, type CSSResultGroup, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import type { SortableEvent } from "sortablejs";
import { DragScrollController } from "../common/controllers/drag-scroll-controller";
import { fireEvent } from "../common/dom/fire_event";
import type { SortableInstance } from "../resources/sortable";
const TOUCH_DRAG_DELAY_MS = 300;
const AUTO_SCROLL_SENSITIVITY = 30;
@customElement("ha-tab-group")
export class HaTabGroup extends TabGroup {
@@ -13,24 +20,199 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
/**
* Allow reordering tabs by dragging or with Alt+ArrowUp/ArrowDown. Fires
* `item-moved`; the owner applies the new order.
*/
@property({ type: Boolean }) public sortable = false;
/** Drag handle that starts a touch drag without the long press. */
@property({ attribute: "handle-selector" }) public handleSelector?: string;
private _sortable?: SortableInstance;
private _sortPlaceholder?: Comment;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
if (this.hasUpdated && this.sortable) {
this._createSortable();
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
this._destroySortable();
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("sortable")) {
// Dragging the tab bar to scroll would compete with dragging a tab.
this._dragScrollController.enabled = !this.sortable;
if (this.sortable) {
this._createSortable();
} else {
this._destroySortable();
}
}
}
private async _createSortable() {
if (this._sortable) {
return;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const Sortable = (await import("../resources/sortable")).default;
if (this._sortable || !this.sortable || !this.isConnected) {
return;
}
// supportPointer is missing from the SortableJS typings.
const options: SortableInstance.Options & { supportPointer?: boolean } = {
draggable: this.tabTag,
// SortableJS guesses vertical from the tabs being display: block.
direction:
this.placement === "start" || this.placement === "end"
? "vertical"
: "horizontal",
// The tab bar scrolls inside the shadow root, where SortableJS can't find it.
scroll: this.nav,
// Force the autoscroll fallback because it works better than the native one
forceAutoScrollFallback: true,
scrollSensitivity: AUTO_SCROLL_SENSITIVITY,
scrollSpeed: 20,
animation: 150,
// Keep swiping on touch screens for scrolling the tab bar.
delay: TOUCH_DRAG_DELAY_MS,
delayOnTouchOnly: true,
// A scrolling swipe ends in pointercancel, after which SortableJS
// ignores the next drag. Touch events end with touchend instead.
supportPointer: false,
onStart: this._handleSortableStart,
onEnd: this._handleSortableEnd,
onUpdate: this._handleSortableUpdate,
};
this._sortable = new Sortable(this, options);
// Capture, so the delay is set before SortableJS reads it.
this.addEventListener("touchstart", this._handleTouchStart, {
capture: true,
passive: true,
});
}
private _destroySortable() {
this._sortable?.destroy();
this._sortable = undefined;
this.removeEventListener("touchstart", this._handleTouchStart, true);
this._removeRtlAutoScrollListeners();
}
private _handleTouchStart = (ev: TouchEvent) => {
const onHandle =
!!this.handleSelector &&
ev
.composedPath()
.some(
(el) => el instanceof Element && el.matches(this.handleSelector!)
);
this._sortable!.option("delay", onHandle ? 0 : TOUCH_DRAG_DELAY_MS);
};
private _handleSortableStart = (evt: SortableEvent) => {
// Put the tab back after dropping, the owner renders the new order.
this._sortPlaceholder = document.createComment("sort-placeholder");
evt.item.after(this._sortPlaceholder);
// Touch drags add a copy of the tab to this element, which the tab group
// would show and select as an extra tab. The dimmed tab shows the position.
this.querySelector(":scope > .sortable-fallback")?.remove();
if (getComputedStyle(this.nav).direction !== "rtl") {
return;
}
// Capture, so this runs before SortableJS checks for auto scroll.
const options = { capture: true, passive: true };
window.addEventListener("dragover", this._unblockRtlAutoScroll, options);
window.addEventListener("touchmove", this._unblockRtlAutoScroll, options);
};
private _removeRtlAutoScrollListeners() {
window.removeEventListener("dragover", this._unblockRtlAutoScroll, true);
window.removeEventListener("touchmove", this._unblockRtlAutoScroll, true);
}
// SortableJS only auto scrolls left while scrollLeft isn't 0, but in RTL the
// tab bar starts at 0 and scrolls left into negative values.
private _unblockRtlAutoScroll = (ev: DragEvent | TouchEvent) => {
const x = "touches" in ev ? ev.touches[0]?.clientX : ev.clientX;
if (x === undefined || this.nav.scrollLeft !== 0) {
return;
}
const left = this.nav.getBoundingClientRect().left;
if (Math.abs(x - left) <= AUTO_SCROLL_SENSITIVITY) {
this.nav.scrollLeft = -1;
}
};
private _handleSortableEnd = (evt: SortableEvent) => {
this._removeRtlAutoScrollListeners();
this._sortPlaceholder?.replaceWith(evt.item);
this._sortPlaceholder = undefined;
};
private _handleSortableUpdate = (evt: SortableEvent) => {
fireEvent(this, "item-moved", {
oldIndex: evt.oldIndex!,
newIndex: evt.newIndex!,
});
};
private _handleKeyDown = (event: KeyboardEvent) => {
// The tab group cancels Enter and Space, so buttons inside a tab would not
// be pressed.
if (
(event.key === "Enter" || event.key === " ") &&
event.composedPath()[0] instanceof HTMLButtonElement &&
(event.target as HTMLElement).closest(this.tabTag)
) {
event.stopPropagation();
return;
}
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
if (
this.sortable &&
event.altKey &&
!(event.ctrlKey || event.metaKey) &&
!event.shiftKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
this._moveTabWithKeyboard(event);
}
};
// Up and down follow the tab order, so they work the same in RTL.
private _moveTabWithKeyboard(event: KeyboardEvent) {
const tab = (event.target as HTMLElement).closest(this.tabTag);
if (!tab) {
return;
}
const tabs = [...this.children].filter(
(el) => el.localName === this.tabTag
);
const oldIndex = tabs.indexOf(tab);
const newIndex = oldIndex + (event.key === "ArrowUp" ? -1 : 1);
event.preventDefault();
if (oldIndex === -1 || newIndex < 0 || newIndex >= tabs.length) {
return;
}
fireEvent(this, "item-moved", { oldIndex, newIndex });
}
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;
@@ -67,6 +249,10 @@ export class HaTabGroup extends TabGroup {
margin-inline-start: calc(-1 * var(--track-width));
}
::slotted(.sortable-ghost) {
opacity: 0.4;
}
.scroll-button::part(base):hover {
background-color: transparent;
}
+22 -219
View File
@@ -41,9 +41,7 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
extractFromTarget,
getTargetComboBoxItemType,
type ExtractFromTargetResult,
type HassServiceTargetWithExclude,
type TargetItem,
type TargetType,
@@ -70,21 +68,15 @@ import "./list/ha-list-base";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-item-row";
import { showTargetDetailsDialog } from "./target-picker/dialog/show-dialog-target-details";
import { filterTargetEntries } from "./target-picker/filter-target-entries";
import { TargetExclusionsController } from "./target-picker/target-exclusions-controller";
import {
addToExclude,
applyExclusionChanges,
computeExclusionsPerTarget,
hasIncludedTargets,
removeFromExclude,
resolveTargetItemEntities,
TARGET_TYPES,
targetItemKey,
targetItems,
withExclude,
type ResolvedTargetItem,
type TargetExclusion,
type TargetExclusions,
} from "./target-picker/target-exclusions";
const SEPARATOR = "________";
@@ -151,24 +143,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _excludeSection?: TargetTypeFloorless;
// What each exclusion takes out of each target, worked out from the value.
@state() private _exclusions?: TargetExclusions;
// Whether every included target and every exclusion resolved, so it is
// known what each exclusion takes out, which ones are shared with another
// target, and the details can edit them.
@state() private _exclusionsComplete = true;
// Every entity the included targets resolve to, so the exclude picker only
// offers what would take something out.
private _includedEntities?: string[];
private _resolveRequest = 0;
// What each target item resolves to, so a change only asks core about the
// items it adds.
private _itemEntries = new Map<string, Promise<ExtractFromTargetResult>>();
@state() private _replaceTarget?: TargetItem;
@state() private _replaceTargetAnchor?: HTMLElement;
@@ -189,6 +163,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _newTarget?: TargetItem;
private _exclusions = new TargetExclusionsController(this);
private _getDevicesMemoized = memoizeOne(
(
hass: HomeAssistant,
@@ -302,9 +278,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
(devicesChanged ||
this.hass.entities !== changedProps.get("hass")?.entities ||
this.hass.areas !== changedProps.get("hass")?.areas);
if (registriesChanged) {
this._itemEntries.clear();
}
if (
changedProps.has("value") ||
changedProps.has("allowExclude") ||
@@ -315,7 +288,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
changedProps.has("entityFilter") ||
registriesChanged
) {
this._resolveExclusions();
this._exclusions.resolve();
}
if (
@@ -351,12 +324,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
? ensureArray(this.value?.label_id)
: [];
const noEffect = this._exclusions?.noEffect ?? [];
const unplaced = this._unplacedExclusions(
this.value?.exclude,
this._exclusions
);
const perTarget = this._exclusionsPerTarget();
const noEffect = this._exclusions.exclusions?.noEffect ?? [];
const unplaced = this._exclusions.unplaced;
const perTarget = this._exclusions.perTarget;
if (
!entityIds.length &&
@@ -497,25 +467,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
`;
}
// Excludes that aren't listed under a target or as having no effect,
// because they are still being resolved or resolving them failed.
private _unplacedExclusions = memoizeOne(
(
exclude: HassServiceTarget | undefined,
exclusions: TargetExclusions | undefined
): TargetItem[] => {
const placed = new Set([
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
list.map(targetItemKey)
),
...(exclusions?.noEffect ?? []).map(targetItemKey),
]);
return targetItems(exclude).filter(
(item) => !placed.has(targetItemKey(item))
);
}
);
private _renderItems() {
return html` ${this._renderValueGroups()} `;
}
@@ -612,155 +563,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
];
}
private _extractItem(
item: TargetItem,
primaryEntitiesOnly: boolean
): Promise<ExtractFromTargetResult> {
const key = `${primaryEntitiesOnly}:${targetItemKey(item)}`;
let entries = this._itemEntries.get(key);
if (!entries) {
entries = extractFromTarget(
this.hass.callWS,
{ [`${item.type}_id`]: [item.id] },
false,
primaryEntitiesOnly
);
this._itemEntries.set(key, entries);
const cached = entries;
cached.catch(() => {
if (this._itemEntries.get(key) === cached) {
this._itemEntries.delete(key);
}
});
}
return entries;
}
// The entities an included item adds, filtered like its row so what an
// exclusion takes out matches what the row counts.
private async _resolveInclude(item: TargetItem): Promise<string[]> {
if (item.type === "entity") {
return resolveTargetItemEntities(this.hass, item);
}
const entries = await this._extractItem(
item,
this.primaryEntitiesOnly ?? true
);
return filterTargetEntries(
this.hass.states,
this.hass.entities,
this.hass.devices,
this.hass.areas,
item,
entries,
{
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
}
).referenced_entities;
}
// Like core, an exclude takes out every entity of an item, not only the
// primary ones, and isn't narrowed by the filters.
private async _resolveExclude(item: TargetItem): Promise<string[]> {
if (item.type === "entity") {
return resolveTargetItemEntities(this.hass, item);
}
return (await this._extractItem(item, false)).referenced_entities;
}
private async _resolveExclusions() {
const request = ++this._resolveRequest;
this._includedEntities = undefined;
const value = this.value;
const excludes = targetItems(value?.exclude);
const includes = targetItems(value);
// Without an exclude, the targets are only needed to tell whether an
// exclusion made in the details is shared with another target.
if (
!this.hass ||
(!excludes.length && (!this.allowExclude || includes.length < 2))
) {
this._exclusions = undefined;
this._exclusionsComplete = true;
return;
}
// Until the targets resolve, the exclusions shown are from before and
// may not say which ones are shared now.
this._exclusionsComplete = false;
// Each item on its own, so it is known which exclusion takes out what
// from which target. One that fails only loses its own part.
const [includeResults, excludeResults] = await Promise.all([
Promise.allSettled(includes.map((item) => this._resolveInclude(item))),
Promise.allSettled(excludes.map((item) => this._resolveExclude(item))),
]);
if (request !== this._resolveRequest) {
return;
}
const resolvedIncludes: ResolvedTargetItem[] = [];
includes.forEach((item, index) => {
const result = includeResults[index];
if (result.status === "fulfilled") {
resolvedIncludes.push({ ...item, entities: result.value });
}
});
const resolvedExcludes: ResolvedTargetItem[] = [];
excludes.forEach((item, index) => {
const result = excludeResults[index];
if (result.status === "fulfilled") {
resolvedExcludes.push({ ...item, entities: result.value });
}
});
const allIncluded = resolvedIncludes.length === includes.length;
const exclusions = computeExclusionsPerTarget(
resolvedIncludes,
resolvedExcludes
);
if (!allIncluded) {
// Without every target, no effect can't be told from not known, and a
// shared exclusion can't be told from one that isn't.
exclusions.noEffect = [];
}
this._exclusions = exclusions;
this._exclusionsComplete =
allIncluded && resolvedExcludes.length === excludes.length;
this._includedEntities = allIncluded
? [
...new Set(
includeResults.flatMap((result) =>
result.status === "fulfilled" ? result.value : []
)
),
]
: undefined;
}
// Without an exclude yet, what the target includes is only needed once the
// exclude picker opens.
private async _resolveIncludedEntities() {
const request = this._resolveRequest;
try {
const included = await Promise.all(
targetItems(this.value).map((item) => this._resolveInclude(item))
);
if (request !== this._resolveRequest) {
return;
}
this._includedEntities = [...new Set(included.flat())];
this._excludePicker?.refreshItems();
} catch (_err) {
// The picker then offers everything instead.
}
}
private _openExcludePicker(ev: Event) {
// exclude picker opens, so it only offers what would take something out.
private async _openExcludePicker(ev: Event) {
this._excludePicker?.open(ev);
if (!this._includedEntities) {
this._resolveIncludedEntities();
if (!this._exclusions.includedEntities) {
await this._exclusions.resolve({ force: true });
this._excludePicker?.refreshItems();
}
}
@@ -791,27 +600,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _exclusionsPerTarget():
Record<string, TargetExclusion[]> | undefined {
return (
this._exclusions?.perTarget ??
(this.allowExclude && !hasIncludedTargets(this.value?.exclude)
? {}
: undefined)
);
}
private _openDetails(ev: HASSDomEvent<HASSDomEvents["open-target-details"]>) {
ev.stopPropagation();
const { type, id, title } = ev.detail;
const key = targetItemKey({ type, id });
const perTarget = this._exclusionsPerTarget();
const perTarget = this._exclusions.perTarget;
const exclusions = perTarget ? (perTarget[key] ?? []) : undefined;
const editable =
exclusions &&
this.allowExclude &&
!this.disabled &&
this._exclusionsComplete;
this._exclusions.complete;
showTargetDetailsDialog(this, {
title,
type,
@@ -824,9 +623,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
primaryEntitiesOnly: this.primaryEntitiesOnly,
exclusions,
exclude: this.value?.exclude,
otherTargetEntities: Object.entries(
this._exclusions?.included ?? {}
).flatMap(([targetKey, entities]) => (targetKey === key ? [] : entities)),
expandGroup: this._exclusions.expandGroup,
explainExclusion: editable
? (item) => this._exclusions.explainExclusion(key, item)
: undefined,
exclusionsChanged: editable
? (initial, current) => {
fireEvent(this, "value-changed", {
@@ -888,7 +688,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return this._getItemsMemoized(
this.hass.localize,
this._excludeEntityFilter(this.entityFilter, this._includedEntities),
this._excludeEntityFilter(
this.entityFilter,
this._exclusions.includedEntities
),
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
@@ -0,0 +1,37 @@
import type { LocalizeFunc } from "../../common/translations/localize";
/**
* The screen reader message for a list search: how many items match, or that
* nothing does. A live region is only read out when its text changes, so the
* message names the search.
*
* Returns undefined when there is no search.
* @param search - The search text
* @param items - The list rows, with section titles as strings
* @param isResult - Whether a row is a result, so placeholder rows and rows
* offered for any search, like "Add custom item", are not counted
* @param localize - The localize function
* @param notFoundLabel - The not found label; one that takes the search is
* used when nothing matches, as it names the search already
*/
export const searchResultsStatus = <T>(
search: string,
items: readonly (T | string)[],
isResult: (item: T) => boolean,
localize?: LocalizeFunc,
notFoundLabel?: string | ((search: string) => string)
): string | undefined => {
if (!search) {
return undefined;
}
const count = items.filter(
(item): item is T => typeof item !== "string" && isResult(item)
).length;
const term = `‘${search}’`;
if (!count) {
return typeof notFoundLabel === "function"
? notFoundLabel(search)
: localize?.("ui.components.combo-box.no_results", { term });
}
return localize?.("ui.components.combo-box.result_count", { count, term });
};
+24 -13
View File
@@ -45,7 +45,7 @@ import {
distanceMeters,
} from "../../common/map/map-engine";
import type { MapStyleConfig } from "../../common/map/map-styles";
import { resolveMapStyle } from "../../common/map/map-styles";
import { isDarkMapStyle, resolveMapStyle } from "../../common/map/map-styles";
import { readMapThemeColors } from "../../common/map/map-theme-colors";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
@@ -618,15 +618,15 @@ export class HaMap extends ReactiveElement {
});
}
if (changedProps.has("mapStyle")) {
this._engine?.setMapStyle(this._resolvedMapStyle);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
) {
// A style can have one half only, so the chrome follows it too
if (changedProps.has("mapStyle")) {
this._updateMapAppearance();
}
return;
}
@@ -647,6 +647,11 @@ export class HaMap extends ReactiveElement {
);
}
// What the map draws, which a style with one half decides over the theme mode
private get _styleDark() {
return isDarkMapStyle(this.mapStyle, this._darkMode);
}
// Memoized: it is read on every appearance sync, and the engines compare
// what they are handed against what they applied.
private _resolveMapStyle = memoizeOne(resolveMapStyle);
@@ -659,12 +664,12 @@ export class HaMap extends ReactiveElement {
// arguments, and a fresh object per read would defeat it.
private _readThemeColors(): void {
// Only one set of --ha-color-map-* is live on the page, the one for the
// mode the page is in. A card forced to the other mode draws the other
// palette, so those values would be the wrong half of the theme; the
// style's own colors are the better answer there.
// mode the page is in. A card forced to the other mode, or a style with
// only the other half, draws the other palette, so those values would be
// the wrong half of the theme; the style's own colors are the better answer.
const pageDark = Boolean(this._ui?.themes?.darkMode);
const colors =
this._darkMode === pageDark ? readMapThemeColors(this) : undefined;
this._styleDark === pageDark ? readMapThemeColors(this) : undefined;
if (!deepEqual(colors, this._themeColors)) {
this._themeColors = colors;
}
@@ -690,13 +695,19 @@ export class HaMap extends ReactiveElement {
return;
}
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("dark", this._styleDark);
map.classList.toggle("drawn", this._mapDrawn);
// The sky belongs behind a drawn globe; on a blank canvas it is just a
// gradient with a glow in it
map.classList.toggle("space", this._vectorEngine && this._mapDrawn);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
map.classList.toggle(
"forced-dark",
this._styleDark && (this.themeMode === "dark" || !this._darkMode)
);
map.classList.toggle(
"forced-light",
!this._styleDark && (this.themeMode === "light" || this._darkMode)
);
this._engine?.setMapStyle(this._resolvedMapStyle);
}
@@ -765,7 +776,7 @@ export class HaMap extends ReactiveElement {
map.id = "map";
// Which ground shows in the gap before the first frame; the rest of the
// classes wait for the engine (_updateMapAppearance)
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("dark", this._styleDark);
this.shadowRoot!.append(map);
this._loading = true;
this._mapDrawn = false;
@@ -0,0 +1,84 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { css, html, LitElement, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import "../animation/ha-fade-in";
import "./ha-skeleton-text";
// Varied widths so the placeholder reads as log lines rather than a block
const LINE_WIDTHS = [72, 58, 86, 64, 44, 78, 52, 90, 60, 70];
const LINE_HEIGHT_EM = 1.5;
/**
* Placeholder for log lines while a log loads. It covers its nearest
* positioned ancestor and shows as many whole lines as fit, so it never
* scrolls. Set `lines` to show that many lines in the flow instead. Like the
* data table's loading rows, the label is given to assistive technology
* rather than shown.
*
* @cssprop --ha-skeleton-log-padding - The space around the lines. Defaults to `var(--ha-space-2)`.
*/
@customElement("ha-skeleton-log")
export class HaSkeletonLog extends LitElement {
@property() public label?: string;
@property({ type: Number, reflect: true }) public lines?: number;
private _fitLineCount = new ResizeController(this, {
callback: (entries) => {
const height = entries[0]?.contentRect.height ?? 0;
const lineHeight =
parseFloat(getComputedStyle(this).fontSize) * LINE_HEIGHT_EM;
return lineHeight ? Math.floor(height / lineHeight) : 0;
},
});
protected render() {
return html`
<ha-fade-in .duration=${300} easing="ease-in">
<div role="progressbar" aria-label=${ifDefined(this.label)}>
${Array.from(
{ length: this.lines ?? this._fitLineCount.value ?? 0 },
(_, i) =>
html`<div class="line">
<ha-skeleton-text
style=${styleMap({
"--ha-skeleton-text-width": `${LINE_WIDTHS[i % LINE_WIDTHS.length]}%`,
})}
></ha-skeleton-text>
</div>`
)}
</div>
</ha-fade-in>
`;
}
static styles = css`
:host {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: var(--ha-skeleton-log-padding, var(--ha-space-2));
overflow: hidden;
}
:host([lines]) {
display: block;
position: static;
}
.line {
display: flex;
align-items: center;
height: ${unsafeCSS(LINE_HEIGHT_EM)}em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-log": HaSkeletonLog;
}
}
@@ -28,12 +28,7 @@ import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetExclusion } from "../target-exclusions";
import {
isShared,
resolveItemExclusions,
resolveTargetItemEntities,
toggleTargetRow,
} from "../target-exclusions";
import { resolveItemExclusions, toggleTargetRow } from "../target-exclusions";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
@@ -54,10 +49,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _exclusionsRequest = 0;
private _otherTargetEntities = memoizeOne(
(entities?: string[]) => new Set(entities)
);
private _deviceIntegrationLookup = memoizeOne(
(entities: HomeAssistant["entities"]) =>
getDeviceIntegrationLookup(Object.values(entities))
@@ -83,7 +74,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
params.exclude,
params.selector
? params.selector.target?.primary_entities_only
: params.primaryEntitiesOnly
: params.primaryEntitiesOnly,
params.expandGroup
);
} catch (_err) {
// The details still show the target, only without what is excluded.
@@ -292,37 +284,32 @@ class DialogTargetDetails extends LitElement implements HassDialog {
...ev.detail,
exclusions: before,
initial: this._initialExclusions,
otherTargetEntities: this._otherTargetEntities(
this._params?.otherTargetEntities
),
});
this._exclusions
.filter(
(exclusion) =>
exclusion.type !== "entity" &&
!before.includes(exclusion) &&
!this._initialExclusions.includes(exclusion)
)
.forEach((exclusion) => this._resolveRemoved(exclusion));
.forEach((exclusion) => this._explainExclusion(exclusion));
}
private async _resolveRemoved(exclusion: TargetExclusion) {
let removed: string[];
private async _explainExclusion(exclusion: TargetExclusion) {
const explain = this._params?.explainExclusion;
if (!explain) {
return;
}
let explained: TargetExclusion | undefined;
try {
removed = await resolveTargetItemEntities(this.hass, exclusion, false);
explained = await explain({ type: exclusion.type, id: exclusion.id });
} catch (_err) {
return;
}
if (!this._exclusions.includes(exclusion)) {
if (!explained || !this._exclusions.includes(exclusion)) {
return;
}
const shared = isShared(
removed,
this._otherTargetEntities(this._params?.otherTargetEntities)
);
this._exclusions = this._exclusions.map((current) =>
current === exclusion ? { ...exclusion, removed, shared } : current
current === exclusion ? explained : current
);
}
@@ -2,7 +2,7 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { TargetSelector } from "../../../data/selector";
import type { TargetType } from "../../../data/target";
import type { TargetItem, TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import type { TargetExclusion } from "../target-exclusions";
@@ -25,10 +25,16 @@ export interface TargetDetailsDialogParams {
*/
exclude?: HassServiceTarget;
/**
* The entities of the other included targets, so an exclusion made here
* is known to also affect them.
* Whether the target expands the groups it names when it is used, like an
* action does and a trigger or condition doesn't. Defaults to `true`.
*/
otherTargetEntities?: string[];
expandGroup?: boolean;
/**
* What excluding `item` would take out of this target, worked out by core
* with the rest of the target value, so it is also known whether it
* affects another target.
*/
explainExclusion?: (item: TargetItem) => Promise<TargetExclusion | undefined>;
/**
* Set to give the rows checkboxes. Called on apply with the exclusions the
* dialog opened with and the ones it has now.
@@ -1,4 +1,5 @@
import {
mdiChartLine,
mdiClose,
mdiDevices,
mdiHome,
@@ -57,6 +58,11 @@ import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import {
getStatisticMetadata,
isExternalStatistic,
type StatisticsMetaData,
} from "../../data/recorder";
import {
deviceMeetsFilter,
extractFromTarget,
@@ -177,6 +183,9 @@ export class HaTargetPickerItemRow extends LitElement {
@state() private _entries?: ExtractFromTargetResult;
/** Metadata of an external statistic picked as entity, which has no state. */
@state() private _statistic?: StatisticsMetaData;
private _fetchedEntries?: ExtractFromTargetResult;
private _lastFetch = 0;
@@ -773,6 +782,19 @@ export class HaTargetPickerItemRow extends LitElement {
this._fetchedEntries = undefined;
if (this.type === "entity") {
this._entries = undefined;
this._statistic = undefined;
if (isExternalStatistic(this.itemId)) {
try {
const [metadata] = await getStatisticMetadata(this._api.callWS, [
this.itemId,
]);
if (requestId === this._lastFetch) {
this._statistic = metadata;
}
} catch {
// Without metadata the statistic is shown as not found
}
}
return;
}
try {
@@ -859,6 +881,15 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this._states?.[item];
// External statistics have no state, only the metadata the recorder keeps.
if (!stateObject && this._statistic?.statistic_id === item) {
return {
name: this._statistic.name || item,
context: domainToName(this._i18n.localize, this._statistic.source),
fallbackIconPath: mdiChartLine,
notFound: false,
};
}
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(
{
@@ -0,0 +1,224 @@
import type { ReactiveControllerHost } from "lit";
import memoizeOne from "memoize-one";
import {
consumeContext,
ContextController,
} from "../../common/decorators/consume";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
explainTarget,
type HassServiceTargetWithExclude,
type TargetItem,
} from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { filterTargetEntries } from "./filter-target-entries";
import { targetExpandGroupContext } from "./target-expand-group-context";
import {
computeExclusionsPerTarget,
hasIncludedTargets,
targetItemKey,
targetItems,
type TargetExclusion,
type TargetExclusions,
type TargetNarrowing,
} from "./target-exclusions";
export type TargetExclusionsHost = ReactiveControllerHost &
HTMLElement & {
hass: HomeAssistant;
value?: HassServiceTargetWithExclude;
allowExclude: boolean;
primaryEntitiesOnly?: boolean;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
includeDeviceClasses?: string[];
};
/**
* What the exclusions of a target picker's value take out of each of its
* targets, as core resolves them, narrowed to what the picker's rows show.
*/
export class TargetExclusionsController extends ContextController {
/** What each exclusion takes out of each target, worked out from the value. */
public exclusions?: TargetExclusions;
/**
* Whether the exclusions belong to the current value, so it is known which
* ones are shared with another target and the details can edit them.
*/
public complete = true;
private _host: TargetExclusionsHost;
@consumeContext({ context: targetExpandGroupContext, subscribe: true })
private _expandGroup?: boolean;
private _request = 0;
constructor(host: TargetExclusionsHost) {
super(host);
this._host = host;
}
protected contextUpdated() {
this.resolve();
}
/** Whether the target expands the groups it names, like an action does. */
public get expandGroup(): boolean {
return this._expandGroup ?? true;
}
/**
* Works the exclusions out again. Without an exclude, the targets are only
* needed to tell whether an exclusion made in the details is shared with
* another target, so they are only resolved for that or when `force`d.
*/
public async resolve({ force = false }: { force?: boolean } = {}) {
const request = ++this._request;
const { hass, value, allowExclude } = this._host;
if (
!hass ||
!value ||
(!force &&
!hasIncludedTargets(value.exclude) &&
(!allowExclude || targetItems(value).length < 2))
) {
this._update(undefined, true);
return;
}
// Until the targets resolve, the exclusions shown are from before and
// may not say which ones are shared now.
this._update(this.exclusions, false);
let exclusions: TargetExclusions | undefined;
try {
exclusions = computeExclusionsPerTarget(
await this._explain(value),
this._keeps()
);
} catch (_err) {
// Without an answer, the exclusions stay unplaced.
}
if (request === this._request) {
this._update(exclusions, !!exclusions);
}
}
/** The exclusions by the target they affect, once there can be any. */
public get perTarget(): Record<string, TargetExclusion[]> | undefined {
return (
this.exclusions?.perTarget ??
(this._host.allowExclude && !hasIncludedTargets(this._host.value?.exclude)
? {}
: undefined)
);
}
/** Every entity the targets include, once known for the current value. */
public get includedEntities(): string[] | undefined {
return this.complete ? this._includedEntities(this.exclusions) : undefined;
}
/**
* Excludes that aren't listed under a target or as having no effect,
* because they are still being resolved or resolving them failed.
*/
public get unplaced(): TargetItem[] {
return this._unplaced(this._host.value?.exclude, this.exclusions);
}
/**
* What excluding `item` on its own would take out of the target
* `targetKey`, with the rest of the value as it is, and whether it affects
* another target too.
*/
public async explainExclusion(
targetKey: string,
item: TargetItem
): Promise<TargetExclusion | undefined> {
const { value } = this._host;
if (!value) {
return undefined;
}
const explained = await this._explain({
...value,
exclude: { [`${item.type}_id`]: [item.id] },
});
return computeExclusionsPerTarget(explained, this._keeps()).perTarget[
targetKey
]?.[0];
}
private _explain(target: HassServiceTargetWithExclude) {
return explainTarget(
this._host.hass.callWS,
target,
this.expandGroup,
this._host.primaryEntitiesOnly ?? true
);
}
private _update(exclusions: TargetExclusions | undefined, complete: boolean) {
this.exclusions = exclusions;
this.complete = complete;
this._host.requestUpdate();
}
// What the picker's filters leave of a target, narrowed like the target's
// row, so what an exclusion takes out matches what the row counts.
private _keeps(): TargetNarrowing {
const {
hass,
deviceFilter,
entityFilter,
includeDomains,
includeDeviceClasses,
primaryEntitiesOnly,
} = this._host;
return (include) =>
filterTargetEntries(
hass.states,
hass.entities,
hass.devices,
hass.areas,
include,
{
referenced_areas: include.referenced_areas,
referenced_devices: include.referenced_devices,
referenced_entities: include.entities,
},
{
deviceFilter,
entityFilter,
includeDomains,
includeDeviceClasses,
primaryEntitiesOnly,
}
).referenced_entities;
}
private _includedEntities = memoizeOne(
(exclusions: TargetExclusions | undefined): string[] | undefined =>
exclusions && [...new Set(Object.values(exclusions.included).flat())]
);
private _unplaced = memoizeOne(
(
exclude: HassServiceTargetWithExclude["exclude"],
exclusions: TargetExclusions | undefined
): TargetItem[] => {
const placed = new Set([
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
list.map(targetItemKey)
),
...(exclusions?.noEffect ?? []).map(targetItemKey),
]);
return targetItems(exclude).filter(
(item) => !placed.has(targetItemKey(item))
);
}
);
}
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more