Compare commits

..
88 Commits
Author SHA1 Message Date
Maarten Lakerveld 1dfbca53b4 Migrate the image cropper dialog to cropperjs 2
Cropper.js 2 is a rewrite built on shadow-DOM custom elements: the v1
class API (getData, getImageData, getCroppedCanvas, the ready and crop
callbacks) and the cropper.css stylesheet no longer exist, so the plain
Renovate bump (#55562) fails type checking and every rspack build.

Render the v2 elements directly from the dialog template instead of
wrapping an <img>. The canvas has no intrinsic size, so decode the file
first and give the canvas the image aspect ratio; the image then fills
it exactly. Initialise after the dialog has finished its scale-in
animation, because the elements measure themselves with
getBoundingClientRect, and refit the image and selection when the
canvas is resized.

Keep the v1 behaviour the callers rely on: the selection cannot leave
the image or the visible canvas, the image cannot be moved or zoomed out
from under it (viewMode 1), resizing stops at 50px, the rounded preview
is CSS only so the exported file stays rectangular, and the export is
rendered at the source resolution. Dirty tracking compares the crop
rectangle in source image pixels, like getData() did in v1.
2026-11-02 21:32:33 +01:00
8995004feb ha-panel-todo migrate list (#55465)
* ha-panel-todo migrate list

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

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

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

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

---------

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

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

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

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

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

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

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

* Always show the settings panel button

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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


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

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

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

* Remove media control card rendering test

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

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

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

* Limit apps card margin change to mobile screens

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

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

* Match installed apps grid spacing to the integrations dashboard

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

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

---------

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

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

* Replace margin-right with margin-inline-end

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

* Align with chart-base

---------

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

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

* Test the picker search results announcement

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

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

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

* Add the picker announcement messages to the gallery demo

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

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

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

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

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

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

* Clear the picker announcement as soon as the rows change

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* use helper

---------

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

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

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

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

* Keep stored logs choices from links and loading apps

* Keep logs choices when closing dialogs and while apps load

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

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

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

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

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

* Test removing the secondary stop control

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-28 18:01:26 +02:00
WendelinandClaude Opus 5.5 10ed6f709e Migrate the data table filters to ha-list-selectable (#55412)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-28 16:55:17 +01:00
Bram KragtenandClaude Opus 5.5 c22de3bc7c Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 16:08:12 +01:00
193 changed files with 5674 additions and 7428 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.
+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+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"
+1 -1
View File
@@ -26,7 +26,7 @@ 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 }}
+3 -3
View File
@@ -31,12 +31,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: 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
@@ -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
+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,7 +284,6 @@ 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),
@@ -292,8 +291,6 @@ 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,
+1 -5
View File
@@ -232,11 +232,7 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-demo-dev",
@@ -1,3 +1,4 @@
/* global module, require */
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
// tagged template literals using `minify-literals` (html-minifier-next +
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
@@ -35,14 +36,6 @@ const initLoader = (browserslistEnv) => {
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
// build (e.g. Shelly Wall Display) can't parse that, so any
// var(--divider-color) border falls back to currentColor. Always
// compile hex alpha colors to rgba() in legacy builds.
lightningcssInclude: browserslistEnv.startsWith("legacy")
? lightningcssModule.Features.HexAlphaColors
: 0,
};
})
);
@@ -74,13 +67,12 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
include: lightningcssInclude,
},
})
)
+8 -21
View File
@@ -214,18 +214,16 @@ const createRspackConfig = ({
},
},
{
// 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.
// 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.
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
parser: {
exprContextCritical: false,
url: false,
},
},
{
@@ -244,22 +242,11 @@ const createRspackConfig = ({
path: paths.root_dir,
env: "legacy",
}),
// Keep rgba() instead of #rrggbbaa, see
// minify-template-literals-loader.cjs
include: { hexAlphaColors: true },
},
},
],
},
// 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: [
+5 -5
View File
@@ -1,5 +1,5 @@
/*
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
Referrer-Policy: no-referrer-when-downgrade
@@ -8,13 +8,13 @@
X-XSS-Protection: 1; mode=block
/images/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/manifest.json
Cache-Control: public, max-age=3600, s-maxage=3600
Cache-Control: public, max-age: 3600, s-maxage=3600
/frontend_es5/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
+1 -2
View File
@@ -1,4 +1,3 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
@@ -215,7 +214,7 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
+5 -12
View File
@@ -1,25 +1,18 @@
/*
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
/api/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/assets/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/frontend_es5/*
Cache-Control: public, max-age=604800, s-maxage=604800
Cache-Control: public, max-age: 604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age=604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
Cache-Control: public, max-age: 604800, s-maxage=604800
-4
View File
@@ -1,4 +0,0 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
-173
View File
@@ -1,173 +0,0 @@
import { ContextEvent } from "@lit/context";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import type { ScriptObject } from "./script-data";
import { readScriptObject } from "./script-data";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so an element inside another component still
// finds the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* The base of the embed elements that render dashboard content. The element
* uses the provider with the ID in `provider`, else the closest provider
* around it, else the first provider on the page. If the page has no
* provider, the element adds an empty one.
*/
export abstract class EmbedElement<
Config extends ScriptObject,
> extends LitElement {
/**
* The configuration of the content, the same as in a dashboard. Also read
* from a JSON or YAML script child, see `readScriptObject`.
*/
@property({ type: Object }) public config?: Config;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
protected _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// element and its content. Context requests only reach ancestors by
// themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const [origin] = ev.composedPath();
const node = origin instanceof HTMLElement ? origin : this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// The page's address belongs to the page, so only a hash on it, such as
// an anchor, navigates there. The browser does that itself, as the app's
// navigate() doesn't scroll to the anchor.
this.addEventListener("hass-navigate", (ev) => {
const { path, replace } = ev.detail;
ev.preventDefault();
if (!path.startsWith("#")) {
this.navigateTo(path, replace);
} else if (replace) {
window.location.replace(path);
} else {
window.location.hash = path;
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the element keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptObject(this);
if (data !== undefined) {
this.setScriptData(data);
}
}
/**
* A navigate action of the content, other than to a hash on the page. It
* leaves the page where it is, like actions that open a dialog. `replace`
* is the action's `navigation_replace`.
*/
protected navigateTo(_path: string, _replace?: boolean) {
// Nothing to navigate to, outside a dashboard
}
/** Whether the data of a script child is a configuration of the content. */
protected abstract isConfig(data: ScriptObject): data is Config;
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
protected setScriptData(data: ScriptObject) {
if (this.isConfig(data)) {
this.config = data;
}
}
/** The content, or `undefined` while there is nothing to render. */
protected abstract renderContent(
hass: HomeAssistant
): TemplateResult | undefined;
// The content in the theme of the provider
protected render() {
const hass = this._hass;
const content = hass && this.renderContent(hass);
if (!hass || !content) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
dir=${computeRTLDirection(hass)}
>
${content}
</div>`;
}
}
-28
View File
@@ -1,28 +0,0 @@
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";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
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");
-99
View File
@@ -1,99 +0,0 @@
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
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 { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
private _card = createRef<HuiCard>();
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
}
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
return html`<hui-card
${ref(this._card)}
.hass=${hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>`;
}
protected updated() {
// 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 = [
embedElementStyles,
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([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
--row-gap
)
);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
-173
View File
@@ -1,173 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/hui-root";
import type { HomeAssistant } from "../../../src/types";
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
* view in `view`, so switching tabs and following links to its views don't
* change the page's URL. The dashboard scrolls inside the element, under its
* toolbar.
*/
@customElement("ha-embed-dashboard")
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
private _scroller = createRef<HTMLDivElement>();
// The route of hui-root, here without a page URL around it. A new
// configuration gives a new route, as hui-root only looks up the view of
// the path again when its route changes, and the views may have moved.
private _route = memoizeOne(
(view: string | undefined, _config: LovelaceConfig) => ({
prefix: "",
path: `/${view ?? ""}`,
})
);
protected get rawConfig() {
return this.config;
}
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
return isDashboardConfig(data);
}
// The view shown, and the views before it, which the back button of a
// subview without a `back_path` goes back through, as the browser's back
// would in the app
private _shownView?: string;
private _history: (string | undefined)[] = [];
// Going back, or a navigate action with `navigation_replace`, which
// replaces the view shown rather than adding to the views before it
private _skipHistory = false;
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this.view === this._shownView) {
return;
}
if (this._shownView !== undefined) {
if (!this._skipHistory) {
this._history.push(this._shownView);
}
this._scroller.value?.scrollTo({ top: 0 });
}
this._skipHistory = false;
this._shownView = this.view;
}
protected navigateTo(path: string, replace?: boolean) {
const view = this.viewOfPath(path);
if (view === undefined || this.isSameView(view, this.view)) {
return;
}
this._skipHistory = Boolean(replace);
this.view = view;
}
// The embedded attribute is in the template, so that hui-root has it when
// it connects and skips the listeners on the page
protected renderContent(hass: HomeAssistant) {
if (!this._lovelace?.config.views.length) {
return undefined;
}
return html`<div class="scroller" ${ref(this._scroller)}>
<hui-root
embedded
no-edit
.hass=${hass}
.lovelace=${this._lovelace}
.route=${this._route(this.view, this._lovelace.config)}
.narrow=${this._narrow.value ?? false}
@view-navigate=${this._viewNavigate}
></hui-root>
</div>`;
}
// A tab, or the back button of a subview, which goes to its `back_path`
// when that is one of the dashboard's views, else to the view before it
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
const view = this.viewOfPath(ev.detail.path);
if (!ev.detail.back) {
if (view !== undefined) {
this.view = view;
}
return;
}
const target = view === undefined ? this._previousView() : view;
// Following a `back_path` goes back too, so the view it goes to leaves
// the views before
if (
view !== undefined &&
this.isSameView(this._history[this._history.length - 1], view)
) {
this._history.pop();
}
if (!this.isSameView(target, this.view)) {
this._skipHistory = true;
this.view = target;
}
}
// The last view before that isn't the one shown, else the first view
private _previousView(): string | undefined {
while (this._history.length) {
const previous = this._history.pop();
if (!this.isSameView(previous, this.view)) {
return previous;
}
}
return undefined;
}
static styles = [
EmbedLovelaceElement.styles,
css`
/* Contained, so that the toolbar and background of hui-root, which are
fixed to the window in the app, are fixed to the element */
:host {
height: 600px;
contain: layout paint;
}
.theme,
.scroller {
height: 100%;
}
.scroller {
overflow: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-dashboard": HaEmbedDashboard;
}
}
-321
View File
@@ -1,321 +0,0 @@
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 { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
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 type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
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[];
devices?: DemoDevice[];
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 embed elements inside it.
* Elements 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`, `devices`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
@property({ attribute: false }) public areas?: DemoArea[];
/** All devices, which entities reference with their `device_id`. */
@property({ attribute: false }) public devices?: DemoDevice[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
*/
@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)
);
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, with the options each entity sets, such
// as timer presets. Other settings, such as calendar colors, are not set.
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
...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,
options:
this.entities.find((item) => item.entity_id === entity.entity_id)
?.options ?? 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.devices = data.devices;
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") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
const areas: DemoArea[] =
this.areas ??
[
...new Set(
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
const devicesById: Record<string, DeviceRegistryEntry> = {};
devices.forEach((device) => {
devicesById[device.id] = toDeviceRegistryEntry(device);
});
// Before the entities, so that their names can include the area and
// device
this.hass.updateHass({ areas: areasById, devices: devicesById });
// Entities that services changed keep their state when only the areas
// or devices 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 };
}
}
-95
View File
@@ -1,95 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
import "../../../src/panels/lovelace/sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../../../src/panels/lovelace/sections/section-container";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a dashboard section, or a section strategy, with the backend of a
* provider, see `EmbedElement`. The section takes the width of the element.
*/
@customElement("ha-embed-section")
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
// A title becomes a heading card, as on a dashboard
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
// A section has cards, sections inside it, a strategy that makes them, a
// title, or the type of a custom section
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
return (
"cards" in data ||
"sections" in data ||
"strategy" in data ||
"title" in data ||
("type" in data && typeof data.type === "string")
);
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// The section that hid the element is gone, and the next one starts
// visible without telling it
if (changedProps.has("config") && !this.config) {
this.hidden = false;
}
}
// In the container that draws the background of the section, as in a
// sections view
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
const config = this._normalizedConfig(this.config);
return renderSectionContainer(
hass,
config,
html`<hui-section
.hass=${hass}
.config=${config}
@section-visibility-changed=${this._visibilityChanged}
></hui-section>`
);
}
private _visibilityChanged(
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
) {
// Sections inside the section fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this.hidden = !ev.detail.value;
}
static styles = [
embedElementStyles,
sectionContainerStyles,
css`
:host {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-section": HaEmbedSection;
}
}
-134
View File
@@ -1,134 +0,0 @@
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import "../../../src/panels/lovelace/views/hui-view";
import "../../../src/panels/lovelace/views/hui-view-background";
import "../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../src/types";
import {
EmbedLovelaceElement,
findViewIndex,
isDashboardConfig,
} from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard view, a view strategy, or one view of a dashboard or
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
* `config` holds the view.
*/
@customElement("ha-embed-view")
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
/**
* A dashboard configuration or dashboard strategy, to show its `view`
* instead of `config`. Also read from the `dashboard` key of a script child,
* next to `view`.
*/
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
private _viewDashboard = memoizeOne(
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
);
protected get rawConfig() {
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
}
// A view strategy and a dashboard strategy both have a `strategy` key, so
// a dashboard comes in its own key, which no view has
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
return !("dashboard" in data);
}
protected setScriptData(data: ScriptObject) {
if (!("dashboard" in data)) {
super.setScriptData(data);
return;
}
const { dashboard } = data;
if (
typeof dashboard === "object" &&
dashboard !== null &&
isDashboardConfig(dashboard)
) {
this.dashboard = dashboard;
}
if (
"view" in data &&
(typeof data.view === "string" || typeof data.view === "number")
) {
this.view ??= String(data.view);
}
}
// Nothing for a dashboard without views, such as an empty strategy result.
// The view's theme and background, or the dashboard's background, as in
// hui-root. Each view gets its own hui-view, as there, so that a view
// strategy still generating for the view before can't replace the cards of
// this one.
protected renderContent(hass: HomeAssistant) {
const views = this._lovelace?.config.views;
if (!this._lovelace || !views?.length) {
return undefined;
}
const index = findViewIndex(views, this.view);
const viewConfig = views[index];
const background =
viewConfig.background || this._lovelace.config.background;
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
<hui-view-background .hass=${hass} .background=${background}>
</hui-view-background>
${keyed(
viewConfig,
html`<hui-view
.hass=${hass}
.lovelace=${this._lovelace}
.index=${index}
.narrow=${this._narrow.value ?? false}
></hui-view>`
)}
</hui-view-container>`;
}
// A fixed background stays inside the element. With a height set on the
// element, a panel view fills it, and other views keep their own height.
static styles = [
EmbedLovelaceElement.styles,
css`
:host {
contain: layout paint;
}
.theme,
hui-view-container,
hui-view {
display: block;
height: 100%;
}
/* The background sits below the view, above the element's own */
hui-view-container {
position: relative;
isolation: isolate;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-view": HaEmbedView;
}
}
-217
View File
@@ -1,217 +0,0 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { property, state } from "lit/decorators";
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import {
checkStrategyShouldRegenerate,
generateLovelaceDashboardStrategy,
} from "../../../src/panels/lovelace/strategies/get-strategy";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
// The width below which the app is narrow, see home-assistant-main
const NARROW_WIDTH = 870;
/** The index of the view with this path or index, if there is one. */
const viewIndex = (
views: LovelaceViewRawConfig[],
view: string
): number | undefined => {
const byPath = views.findIndex((item) => item.path === view);
if (byPath !== -1) {
return byPath;
}
const index = Number(view);
return Number.isInteger(index) && index >= 0 && index < views.length
? index
: undefined;
};
/** Whether script data is a dashboard: its views, or a strategy for them. */
export const isDashboardConfig = (
data: ScriptObject
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
/** The index of the view with this path or index, else the first view. */
export const findViewIndex = (
views: LovelaceViewRawConfig[],
view?: string
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
/**
* The base of the embed elements that render a dashboard, or one of its
* views. It generates dashboard strategies, again when the registries they
* depend on change, and holds a dashboard that can't be edited or saved.
*/
export abstract class EmbedLovelaceElement<
Config extends ScriptObject,
> extends EmbedElement<Config> {
/** The path or index of the view to show. Without it, the first view. */
@property() public view?: string;
@state() protected _lovelace?: Lovelace;
// Narrow like the app at the same width, measured on the element because
// it rarely takes the width of the page
protected _narrow = new ResizeController(this, {
callback: (entries) =>
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
});
// The configuration of the dashboard in `_lovelace`, or being generated
private _rawConfig?: LovelaceRawConfig;
// Counts the generations started, so that only the latest one is shown.
// Generating again for changed registries keeps the same configuration.
private _generation = 0;
// The backend at the last update, to tell what a strategy depends on changed
private _previousHass?: HomeAssistant;
/** The dashboard to render. The same object until it changes. */
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
/**
* The view of the dashboard a path goes to, relative like `batteries` or
* with the dashboard like `/maintenance/batteries`, if it has that view.
*/
protected viewOfPath(path: string | undefined): string | undefined {
const views = this._lovelace?.config.views;
const view = path
? decodeURI(new URL(path, "http://embed/").pathname)
.split("/")
.filter(Boolean)
.pop()
: undefined;
return views && view !== undefined && viewIndex(views, view) !== undefined
? view
: undefined;
}
/** Whether two paths or indexes go to the same view of the dashboard. */
protected isSameView(a?: string, b?: string): boolean {
const views = this._lovelace?.config.views;
return views
? findViewIndex(views, a) === findViewIndex(views, b)
: a === b;
}
// A path to one of the dashboard's views shows that view. Other paths leave
// the dashboard where it is.
protected navigateTo(path: string) {
const view = this.viewOfPath(path);
if (view !== undefined) {
this.view = view;
}
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
const raw = this.rawConfig;
const oldHass = this._previousHass;
this._previousHass = this._hass;
// Without a configuration, nothing is shown, and a generation still
// running is dropped
if (!raw) {
if (this._rawConfig) {
this._rawConfig = undefined;
this._lovelace = undefined;
this._generation++;
}
return;
}
if (!this._hass) {
return;
}
// A strategy is only asked whether to generate again when the backend
// changed, as in ha-panel-lovelace, not when the generated dashboard
// arrives
if (
raw !== this._rawConfig ||
(oldHass &&
oldHass !== this._hass &&
isStrategyDashboard(raw) &&
this._hass.config.state === "RUNNING" &&
checkStrategyShouldRegenerate(
"dashboard",
raw.strategy,
oldHass,
this._hass
))
) {
this._rawConfig = raw;
this._generate(raw, this._hass, ++this._generation);
}
}
private async _generate(
raw: LovelaceRawConfig,
hass: HomeAssistant,
generation: number
) {
const generated = isStrategyDashboard(raw)
? await generateLovelaceDashboardStrategy(raw, hass)
: raw;
const config = normalizeLovelaceConfig(generated);
// A newer generation has started. A generated dashboard has its views,
// rather than a strategy.
if (generation !== this._generation || isStrategyDashboard(config)) {
return;
}
this._lovelace = {
config,
rawConfig: raw,
editMode: false,
urlPath: null,
mode: "generated",
locale: hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
embedded: true,
};
}
// The background of a dashboard, and no insets for a notch or home bar
static styles: CSSResultGroup = [
embedElementStyles,
css`
:host {
display: block;
--app-safe-area-inset-top: 0px;
--app-safe-area-inset-bottom: 0px;
--app-safe-area-inset-left: 0px;
--app-safe-area-inset-right: 0px;
}
.theme {
position: relative;
background: var(--primary-background-color);
}
`,
];
}
-20
View File
@@ -1,20 +0,0 @@
/* 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 {};
-31
View File
@@ -1,31 +0,0 @@
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!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
-95
View File
@@ -1,95 +0,0 @@
import { css, unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
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("")}}`
);
/**
* The styles of every embed element: the theme, and the page styles of the
* app, for the `.theme` wrapper of `EmbedElement`.
*/
export const embedElementStyles = [
embedThemeStyles,
css`
:host([hidden]) {
display: none;
}
/* The text styles the app sets on html and body, so that the content does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the content */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
-32
View File
@@ -1,32 +0,0 @@
// 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,7 +36,6 @@ 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";
@@ -51,7 +50,6 @@ 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.
@@ -77,7 +75,6 @@ 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",
@@ -193,9 +190,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
import "<%= latestEntryJS[0] %>";
+13 -15
View File
@@ -12,20 +12,6 @@ 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[] = []
@@ -39,7 +25,19 @@ export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map(toAreaRegistryEntry);
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,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+120 -10
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,6 +20,109 @@ 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,
@@ -125,20 +228,27 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
mockExtractFromTarget(hass);
hass.mockWS(
"explain_target",
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
) =>
explainTarget(
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
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
@@ -1,122 +0,0 @@
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") },
],
-54
View File
@@ -1,60 +1,6 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/** The fields of a device that the demo devices leave at their default. */
export const baseDevice: Omit<
DeviceRegistryEntry,
| "id"
| "name"
| "manufacturer"
| "model"
| "config_entries"
| "primary_config_entry"
| "entry_type"
> = {
config_entries_subentries: {},
connections: [],
identifiers: [],
model_id: null,
labels: [],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export interface DemoDevice {
id: string;
name: string;
manufacturer?: string;
model?: string;
area_id?: string;
via_device_id?: string;
entry_type?: "service";
}
export const toDeviceRegistryEntry = (
device: DemoDevice
): DeviceRegistryEntry => ({
...baseDevice,
id: device.id,
name: device.name,
manufacturer: device.manufacturer ?? null,
model: device.model ?? null,
area_id: device.area_id ?? null,
via_device_id: device.via_device_id ?? null,
entry_type: device.entry_type ?? null,
config_entries: [],
primary_config_entry: null,
});
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
+19 -1
View File
@@ -1,6 +1,24 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
import { baseDevice } from "./device_registry";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export const demoDevices: DeviceRegistryEntry[] = [
{
+20 -20
View File
@@ -11,7 +11,7 @@ export const energyEntities = () =>
entity_id: "sensor.solar_production",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Solar",
unit_of_measurement: "kWh",
},
@@ -20,7 +20,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_input",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Input",
unit_of_measurement: "kWh",
},
@@ -29,7 +29,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_output",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
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: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
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: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
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: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
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: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
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: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -82,7 +82,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -90,7 +90,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -98,7 +98,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -138,7 +138,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -146,7 +146,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
@@ -155,7 +155,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Water",
unit_of_measurement: "L",
},
@@ -164,7 +164,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_car",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Electric car",
unit_of_measurement: "kWh",
},
@@ -173,7 +173,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_ac",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Air conditioning",
unit_of_measurement: "kWh",
},
@@ -182,7 +182,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_washing_machine",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Washing machine",
unit_of_measurement: "kWh",
},
@@ -191,7 +191,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_dryer",
state: "5.5",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Dryer",
unit_of_measurement: "kWh",
},
@@ -200,7 +200,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_heat_pump",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Heat pump",
unit_of_measurement: "kWh",
},
@@ -209,7 +209,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_boiler",
state: "7",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Boiler",
unit_of_measurement: "kWh",
},
+2 -14
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
export const newEntry = (
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
@@ -43,9 +43,7 @@ const displayFields = (entry: EntityRegistryEntry) => ({
hidden: entry.hidden_by !== null,
});
export const extEntry = (
entry: EntityRegistryEntry
): ExtEntityRegistryEntry => ({
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
@@ -58,16 +56,6 @@ 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
@@ -1,79 +0,0 @@
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
@@ -1,15 +0,0 @@
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,7 +149,6 @@ 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],
+1 -3
View File
@@ -372,9 +372,7 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState &&
(entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
entityState && "last_reset" in entityState.attributes
? generateSumStatistics(
start,
end,
-132
View File
@@ -1,132 +0,0 @@
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
@@ -1,133 +0,0 @@
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;
}
);
};
+77 -10
View File
@@ -1,14 +1,68 @@
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,
@@ -25,8 +79,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
start_time: at(0, 8),
end_time: at(0, 8, 30),
location: "",
description: "",
supported_features:
@@ -42,8 +96,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
start_time: at(0, 9),
end_time: at(0, 9, 15),
},
},
{
@@ -91,7 +145,20 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
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;
});
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
+30
View File
@@ -48,6 +48,36 @@ export const createMediaPlayerEntities = () => [
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.music_all_features",
state: "playing",
attributes: {
friendly_name: "Playing The Music (all features)",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + On + Off +
// Play Media + Volume Step + Select Source + Stop + Clear + Play + Shuffle Set +
// Sound Mode + Browse Media + Repeat Set + Grouping
supported_features: 1048511,
entity_picture: "/images/album_cover.jpg",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
// 23 seconds in
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
is_volume_muted: false,
shuffle: false,
repeat: "off",
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.stream_playing",
state: "playing",
@@ -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,7 +5,6 @@ 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";
@@ -226,20 +225,6 @@ 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);
}
@@ -25,6 +25,13 @@ const CONFIGS = [
entity: "media_player.music_playing",
},
},
{
heading: "Playing Music (all features)",
config: {
type: "media-control",
entity: "media_player.music_all_features",
},
},
{
heading: "Playing Stream",
config: {
+17 -17
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",
@@ -78,18 +78,18 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "7.0.1",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@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",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.51.0",
"cropperjs": "1.7.0",
"cropperjs": "2.3.1",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
@@ -100,8 +100,8 @@
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -110,7 +110,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.1",
"marked": "18.1.2",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -137,7 +137,7 @@
},
"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",
@@ -153,8 +153,8 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@rspack/core": "2.3.0",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
"@types/chromecast-caf-sender": "1.0.11",
@@ -173,7 +173,7 @@
"@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",
@@ -188,7 +188,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"glob": "14.0.3",
"globals": "17.13.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.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"
}
}
+897 -762
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20261028.5"
version = "20261028.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Return a copy of the list with the value in it, without adding duplicates
* @param values - The current list
* @param value - The value to add
*/
export const arrayWith = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values?.includes(value) ? [...values] : [...(values ?? []), value]);
/**
* Return a copy of the list without the value
* @param values - The current list
* @param value - The value to remove
*/
export const arrayWithout = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values ?? []).filter((v) => v !== value);
@@ -3,12 +3,6 @@ import { ref } from "lit/directives/ref";
const MAX_ROWS = 2;
// A narrow list, as on a phone or a half width card, fits about one item per
// row, so two rows hide most of the legend. 450px is about the widest list a
// phone viewport gives.
const NARROW_MAX_ROWS = 3;
const NARROW_WIDTH = 450;
// How many items fit in maxRows of a wrapping row, leaving room for the
// "more" item after them when not all of them fit.
export const fitInRows = (
@@ -40,10 +34,9 @@ export const fitInRows = (
return widths.length;
};
// Fits the items of a wrapping legend list into two rows, three when it is
// narrow. The list keeps its "more" item rendered with the legend-more class,
// and items past the fit stay rendered out of the flow, so both can always be
// measured.
// Fits the items of a wrapping legend list into two rows. The list keeps its
// "more" item rendered with the legend-more class, and items past the fit
// stay rendered out of the flow, so both can always be measured.
export class LegendFitController {
// Items to show while collapsed, undefined until the list is measured
public fit?: number;
@@ -125,7 +118,7 @@ export class LegendFitController {
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
MAX_ROWS
);
if (fit !== this.fit) {
this.fit = fit;
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
* Gets the `popstate` or `location-changed` event, or nothing when the host
* is connected again.
*/
navigated: () => void;
navigated: (ev?: Event) => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
@@ -159,7 +161,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
this._options.navigated(ev);
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
export const URL_ATTRIBUTES = [
const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
+6 -11
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_PATH__}map/light.json`,
dark: `${__STATIC_PATH__}map/dark.json`,
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
@@ -92,12 +92,8 @@ 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, filesBase()).href;
const absoluteSprite = (url: string) => new URL(url, location.href).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
@@ -145,15 +141,14 @@ 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. MapLibre
// starts a worker from another origin itself.
// from the build: the worker is an entry of its own, next to the app.
let workerUrlSet = false;
export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
if (workerUrlSet) {
return;
}
workerUrlSet = true;
setUrl(new URL(__MAPLIBRE_WORKER_URL__, filesBase()).href);
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
};
const createVectorLayer = async (
+1 -1
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_PATH__}map/urls.json?v=${__VERSION__}`;
const STYLE_URLS_PATH = `/static/map/urls.json?v=${__VERSION__}`;
let urls: Promise<OsmOptions["urls"]> | undefined;
@@ -78,7 +78,7 @@ const POSITIONS: Record<
bottomright: "bottom-right",
};
const MAPLIBRE_CSS_URL = `${__STATIC_PATH__}map/maplibre-gl.css`;
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
// Fully hidden behind the globe, not MapLibre's faint ghost
const MARKER_OPTIONS = { opacityWhenCovered: 0 };
@@ -1,4 +1,4 @@
import { provide, type ContextType } from "@lit/context";
import 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,14 +17,9 @@ 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;
@@ -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;
}
`,
];
}
+21 -12
View File
@@ -1,6 +1,5 @@
import { TZDate } from "@date-fns/tz";
import type { ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
@@ -17,6 +16,7 @@ import {
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
@@ -32,8 +32,8 @@ import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
@@ -166,11 +166,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
return html`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
<ha-list-base>
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-list-item-button
.range=${range}
.index=${index}
@click=${this._setDateRange}
>
<span slot="headline">${name}</span>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -343,11 +351,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
private _setDateRange(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { range: [Date, Date]; index: number }
>
) {
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
+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() {
+3 -9
View File
@@ -13,7 +13,6 @@ import {
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
getAreaEntityIds,
type AreaControlDomain,
} from "../data/area/area_controls";
import {
@@ -149,20 +148,15 @@ export class HaAreaControlsPicker extends LitElement {
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const areaEntities = getAreaEntityIds(
registries.entities,
registries.devices,
areaId,
excludeEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
AREA_CONTROL_DOMAINS
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities
);
const items: (AreaControlPickerItem | string)[] = [];
@@ -26,10 +26,7 @@ import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockDateSeparatorPart = Extract<ClockCardDatePart, `separator-${string}`>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
@@ -43,6 +40,7 @@ const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-comma": ",",
"separator-new-line": "",
};
+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>
`
+40 -32
View File
@@ -1,5 +1,4 @@
import type { ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -12,6 +11,11 @@ import {
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import type { Blueprints } from "../data/blueprint";
@@ -19,11 +23,12 @@ import { fetchBlueprints } from "../data/blueprint";
import { apiContext } from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@@ -88,23 +93,30 @@ export class HaFilterBlueprints extends LitElement {
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
<ha-list-selectable
multi
class="ha-scrollbar"
aria-label=${this._localize(
"ui.panel.config.blueprint.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
: html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${id}
.selected=${(this.value || []).includes(id)}
.selected=${this.value?.includes(id) ?? false}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
<span slot="headline"
>${blueprint.metadata.name || id}</span
>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
`
: nothing
}
@@ -126,28 +138,20 @@ export class HaFilterBlueprints extends LitElement {
this.expanded = ev.detail.expanded;
}
private async _blueprintsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const blueprints = this._blueprints!;
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
const blueprintId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!blueprintId) {
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const blueprintId = Object.keys(blueprints)[index];
value.push(blueprintId);
}
this.value = value;
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, blueprintId)
: arrayWithout(this.value, blueprintId);
}
private async _findRelated() {
@@ -202,10 +206,14 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
+11 -7
View File
@@ -35,12 +35,12 @@ import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistantConnection } from "../types";
import "./ha-button";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list-item";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -226,10 +226,14 @@ export class HaFilterCategories extends LitElement {
</ha-list-item-option>`
)}
</ha-list-selectable>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
<ha-button
appearance="filled"
size="s"
@click=${this._addCategory}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</ha-button>
</div>
`
: nothing
@@ -386,12 +390,12 @@ export class HaFilterCategories extends LitElement {
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-button {
margin: var(--ha-space-2) var(--ha-space-4);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
ha-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
ha-dropdown-item {
font-size: var(--ha-font-size-m);
}
+9 -1
View File
@@ -85,6 +85,15 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(properties: PropertyValues<this>) {
super.updated(properties);
// The rows don't change with the value, so render the selection again
if (properties.has("value")) {
this._listElement?.requestRowsUpdate();
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -269,7 +278,6 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static styles = [
+44 -38
View File
@@ -1,5 +1,4 @@
import type { ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -13,17 +12,23 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -82,35 +87,38 @@ export class HaFilterDomains extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
<ha-list-selectable
multi
aria-label=${this._localize("ui.panel.config.domains.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
this._filter
),
(i) => i,
(domain) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
.selected=${this.value?.includes(domain) ?? false}
>
<ha-domain-icon
slot="graphic"
slot="start"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
<span slot="headline"
>${domainToName(this._localize, domain)}</span
>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
</div>`
: nothing
}
@@ -122,8 +130,7 @@ export class HaFilterDomains extends LitElement {
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
_value
filter: string | undefined
) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
@@ -150,22 +157,20 @@ export class HaFilterDomains extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
);
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const domain = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!domain) {
return;
}
const visibleDomains = new Set(domains);
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
const selected = [...ev.detail.index]
.map((i) => domains[i])
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, domain)
: arrayWithout(this.value, domain);
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
@@ -189,10 +194,14 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
@@ -204,9 +213,6 @@ export class HaFilterDomains extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;
+58 -62
View File
@@ -3,7 +3,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
@@ -12,6 +12,7 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
@@ -25,16 +26,16 @@ import {
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./ha-state-icon";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable-virtualized";
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
interface EntityItem {
interface EntityItem extends HaListVirtualizedItem {
stateObj: HassEntity;
name: string;
}
@@ -74,15 +75,14 @@ export class HaFilterEntities extends LitElement {
private _panel = new FilterPanelController(this, this._content);
@query("ha-list-selectable-virtualized")
private _list?: HaListSelectableVirtualized;
private _relatedRequestId = 0;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
@@ -91,6 +91,15 @@ export class HaFilterEntities extends LitElement {
}
}
protected updated(properties: PropertyValues<this>) {
super.updated(properties);
// The rows don't change with the value, so render the selection again
if (properties.has("value")) {
this._list?.requestRowsUpdate();
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -121,23 +130,14 @@ export class HaFilterEntities extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this._states,
this._formatters,
this.type,
this._filter || "",
this._i18n.locale.language,
this.value
)}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>
<ha-list-selectable-virtualized
multi
aria-label=${this._localize("ui.panel.config.entities.caption")}
.rows=${this._currentEntities()}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
></ha-list-selectable-virtualized>
</div>
`
: nothing
@@ -145,43 +145,43 @@ export class HaFilterEntities extends LitElement {
`;
}
private _keyFunction = (item?: EntityItem) => item?.stateObj.entity_id;
private _renderItem = (item?: EntityItem) =>
!item
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${item.stateObj.entity_id}
.selected=${this.value?.includes(item.stateObj.entity_id) ?? false}
graphic="icon"
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<ha-state-icon
slot="graphic"
slot="start"
.stateObj=${item.stateObj}
></ha-state-icon>
${item.name}
</ha-check-list-item>`;
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
private _handleItemToggled(ev: CustomEvent<number>) {
const entityId = this._currentEntities()[ev.detail]?.id;
if (!entityId) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, entityId)
: arrayWithout(this.value, entityId);
}
private _currentEntities() {
return this._entities(
this._states,
this._formatters,
this.type,
this._filter || "",
this._i18n.locale.language
);
}
private _expandedChanged(ev) {
@@ -199,12 +199,13 @@ export class HaFilterEntities extends LitElement {
formatters: ContextType<typeof formattersContext>,
type: this["type"],
filter: string,
language: string | undefined,
_value
language: string | undefined
): EntityItem[] =>
Object.values(states)
.filter((stateObj) => !type || computeStateDomain(stateObj) !== type)
.map((stateObj) => ({
id: stateObj.entity_id,
interactive: true,
stateObj,
name: formatters.formatEntityName(stateObj),
}))
@@ -267,10 +268,9 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
@@ -298,10 +298,6 @@ export class HaFilterEntities extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
width: 100%;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
+43 -40
View File
@@ -1,4 +1,3 @@
import type { SelectedDetail } from "@material/mwc-list";
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
@@ -13,17 +12,23 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, manifestsContext } from "../data/context";
import type { IntegrationManifest } from "../data/integration";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -92,37 +97,41 @@ export class HaFilterIntegrations extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
<ha-list-selectable
multi
aria-label=${this._localize(
"ui.panel.config.integrations.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
.selected=${
this.value?.includes(integration.domain) ??
false
}
>
<ha-domain-icon
slot="graphic"
slot="start"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
<span slot="headline">${integration.name}</span>
</ha-list-item-option>`
)}
</ha-list>`
</ha-list-selectable>`
: nothing
}
</div>`
@@ -144,7 +153,6 @@ export class HaFilterIntegrations extends LitElement {
localize: LocalizeFunc,
manifest: IntegrationManifest[],
filter: string | undefined,
_value: string[] | undefined,
language: string
) =>
manifest
@@ -165,26 +173,20 @@ export class HaFilterIntegrations extends LitElement {
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _itemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!this._manifests) {
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const domain = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!domain) {
return;
}
const integrations = this._integrations(
this._localize,
this._manifestList(this._manifests),
this._filter,
this.value,
this._i18n.locale.language
);
const visibleDomains = new Set(integrations.map((i) => i.domain));
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
const selected = [...ev.detail.index]
.map((i) => integrations[i]?.domain)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, domain)
: arrayWithout(this.value, domain);
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
@@ -208,10 +210,14 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
@@ -223,9 +229,6 @@ export class HaFilterIntegrations extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;
+51 -49
View File
@@ -1,5 +1,4 @@
import type { ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -13,22 +12,29 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-button";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-base";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-labels")
export class HaFilterLabels extends LitElement {
@@ -57,12 +63,10 @@ export class HaFilterLabels extends LitElement {
private _panel = new FilterPanelController(this, this._content);
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
(
labels: LabelRegistryEntry[],
filter: string | undefined,
language: string | undefined,
_value
language: string | undefined
) =>
labels
.filter(
@@ -105,26 +109,28 @@ export class HaFilterLabels extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
<ha-list-selectable
multi
aria-label=${this._localize("ui.panel.config.labels.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
this._i18n.locale.language
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
.selected=${this.value?.includes(label.label_id) ?? false}
>
<ha-label
slot="headline"
.color=${label.color}
.description=${label.description}
>
@@ -138,13 +144,17 @@ export class HaFilterLabels extends LitElement {
}
${label.name}
</ha-label>
</ha-check-list-item>`
</ha-list-item-option>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</ha-list-selectable>
<ha-list-base>
<ha-list-item-button @click=${this._manageLabels}>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline"
>${this._localize("ui.panel.config.labels.manage_labels")}</span
>
</ha-list-item-button>
</ha-list-base>
</div>`
: nothing
}
@@ -164,35 +174,23 @@ export class HaFilterLabels extends LitElement {
this._filter = value.toLowerCase();
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const filteredLabels = this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
);
const filteredLabelIds = new Set(filteredLabels.map((l) => l.label_id));
// Keep previously selected labels that are not in the current filtered view
const preservedLabels = (this.value || []).filter(
(id) => !filteredLabelIds.has(id)
);
// Build the new selection from the filtered labels based on selected indices
const newlySelectedLabels: string[] = [];
for (const index of ev.detail.index) {
const labelId = filteredLabels[index]?.label_id;
if (labelId) {
newlySelectedLabels.push(labelId);
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const labelId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!labelId) {
return;
}
const value = [...preservedLabels, ...newlySelectedLabels];
this.value = value.length ? value : [];
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, labelId)
: arrayWithout(this.value, labelId);
fireEvent(this, "data-table-filter-changed", {
value: value.length ? value : undefined,
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
@@ -208,10 +206,14 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
+39 -31
View File
@@ -1,20 +1,26 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@@ -40,7 +46,6 @@ export class HaFilterStates extends LitElement {
if (!this.states) {
return nothing;
}
const hasIcon = this.states.find((item) => item.icon);
return html`
<ha-expansion-panel
left-chevron
@@ -64,30 +69,32 @@ export class HaFilterStates extends LitElement {
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._statesSelected}
<ha-list-selectable
multi
class="ha-scrollbar"
aria-label=${ifDefined(this.label)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${this.states.map(
(item) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${item.value}
.selected=${this.value?.includes(item.value) ?? false}
.graphic=${hasIcon ? "icon" : null}
>
${
item.icon
? html`<ha-icon
slot="graphic"
slot="start"
.icon=${item.icon}
></ha-icon>`
: nothing
}
${item.label}
</ha-check-list-item>`
<span slot="headline">${item.label}</span>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
</div>
`
: nothing
@@ -99,26 +106,23 @@ export class HaFilterStates extends LitElement {
this.expanded = ev.detail.expanded;
}
private async _statesSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const state = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!state) {
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const val = this.states![index].value;
value.push(val);
}
this.value = value;
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, state)
: arrayWithout(this.value, state);
fireEvent(this, "data-table-filter-changed", {
value,
value: this.value,
items: undefined,
});
}
@@ -134,10 +138,14 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
+41 -34
View File
@@ -1,4 +1,3 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -11,14 +10,19 @@ import {
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
@@ -64,30 +68,35 @@ export class HaFilterVoiceAssistants extends LitElement {
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
<ha-list-selectable
multi
aria-label=${this._localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._voiceAssistantOptions,
(voiceAssistantId) => voiceAssistantId,
(voiceAssistantId) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${voiceAssistantId}
.selected=${(this.value || []).includes(voiceAssistantId)}
hasMeta
graphic="icon"
.selected=${this.value?.includes(voiceAssistantId) ?? false}
>
<voice-assistant-brand-icon
slot="graphic"
slot="start"
.voiceAssistantId=${voiceAssistantId}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
<span slot="headline"
>${voiceAssistants[voiceAssistantId].name}</span
>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
</div>`
: nothing
}
@@ -103,23 +112,20 @@ export class HaFilterVoiceAssistants extends LitElement {
this.expanded = ev.detail.expanded;
}
private async _assistantsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
const voiceAssistantId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!voiceAssistantId) {
return;
}
const newvalue: string[] = [];
for (const index of ev.detail.index) {
newvalue.push(this._voiceAssistantOptions![index]);
}
this.value = newvalue;
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, voiceAssistantId)
: arrayWithout(this.value, voiceAssistantId);
fireEvent(this, "data-table-filter-changed", {
value: this.value,
@@ -138,10 +144,14 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
@@ -153,9 +163,6 @@ export class HaFilterVoiceAssistants extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;
-2
View File
@@ -86,7 +86,6 @@ class HaMarkdownElement extends ReactiveElement {
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
rewriteUrl: Boolean(this.rewriteUrl),
});
}
@@ -100,7 +99,6 @@ class HaMarkdownElement extends ReactiveElement {
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
allowRelativeUrls: Boolean(this.rewriteUrl),
}
);
+78 -27
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
@@ -432,12 +477,6 @@ 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>`
)}
@@ -462,8 +501,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) || [])];
@@ -505,15 +561,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") {
@@ -674,15 +722,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;
@@ -1028,6 +1068,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;
}
`,
];
}
-52
View File
@@ -1,52 +0,0 @@
import { css } from "lit";
import { RadioListItemBase } from "@material/mwc-list/mwc-radio-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-radio-list-item")
export class HaRadioListItem extends RadioListItemBase {
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
.mdc-deprecated-list-item__meta {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--radio-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-list-item": HaRadioListItem;
}
}
@@ -1,7 +1,25 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { ContextType } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
import type { UiClockDateFormatSelector } from "../../data/selector";
import {
CLOCK_CARD_DATE_PRESETS,
formatClockCardDatePreset,
getClockCardDatePresetParts,
isClockCardDatePreset,
} from "../../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePreset } from "../../panels/lovelace/cards/types";
import "../ha-clock-date-format-picker";
import "../ha-select";
import type { HaSelectOption, HaSelectSelectEvent } from "../ha-select";
type ClockDateFormatMode = ClockCardDatePreset | "custom";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@@ -17,17 +35,130 @@ export class HaSelectorUiClockDateFormat extends LitElement {
@property({ type: Boolean }) public required = true;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@state() private _custom = false;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
changedProperties.has("value") &&
Array.isArray(this.value) &&
this.value.length
) {
this._custom = true;
}
}
private get _mode(): ClockDateFormatMode | undefined {
if (isClockCardDatePreset(this.value)) {
return this.value;
}
return this._custom ? "custom" : undefined;
}
private _options = memoizeOne(
(
i18n: ContextType<typeof internationalizationContext>,
serverTimeZone: string
): HaSelectOption[] => {
const now = new Date();
const timeZone = resolveTimeZone(i18n.locale.time_zone, serverTimeZone);
return [
...CLOCK_CARD_DATE_PRESETS.map((preset) => ({
value: preset,
label: i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.presets.${preset}`
),
secondary: formatClockCardDatePreset(
now,
preset,
i18n.locale.language,
timeZone
),
})),
{
value: "custom",
label: i18n.localize(
"ui.panel.lovelace.editor.card.clock.date.presets.custom"
),
},
];
}
);
protected render() {
const mode = this._mode;
return html`
<ha-clock-date-format-picker
.value=${this.value}
<ha-select
.label=${this.label}
.helper=${this.helper}
.value=${mode}
.options=${this._options(this._i18n, this._hassConfig.config.time_zone)}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
.clearable=${!this.required}
@selected=${this._modeSelected}
></ha-select>
${
mode === "custom"
? html`
<ha-clock-date-format-picker
.value=${this.value}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`
: nothing
}
`;
}
private _modeSelected(
ev: HaSelectSelectEvent<ClockDateFormatMode, true>
): void {
ev.stopPropagation();
const mode = ev.detail.value;
const currentValue = this.value;
this._custom = mode === "custom";
if (mode === "custom") {
if (isClockCardDatePreset(currentValue)) {
fireEvent(this, "value-changed", {
value: getClockCardDatePresetParts(
currentValue,
this._i18n.locale.language
),
});
}
return;
}
fireEvent(this, "value-changed", { value: mode });
}
static styles = css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
ha-select {
display: block;
width: 100%;
}
`;
}
declare global {
+219 -22
View File
@@ -41,7 +41,9 @@ 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,
@@ -68,15 +70,21 @@ 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 { TargetExclusionsController } from "./target-picker/target-exclusions-controller";
import { filterTargetEntries } from "./target-picker/filter-target-entries";
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 = "________";
@@ -143,6 +151,24 @@ 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;
@@ -163,8 +189,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _newTarget?: TargetItem;
private _exclusions = new TargetExclusionsController(this);
private _getDevicesMemoized = memoizeOne(
(
hass: HomeAssistant,
@@ -278,6 +302,9 @@ 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") ||
@@ -288,7 +315,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
changedProps.has("entityFilter") ||
registriesChanged
) {
this._exclusions.resolve();
this._resolveExclusions();
}
if (
@@ -324,9 +351,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
? ensureArray(this.value?.label_id)
: [];
const noEffect = this._exclusions.exclusions?.noEffect ?? [];
const unplaced = this._exclusions.unplaced;
const perTarget = this._exclusions.perTarget;
const noEffect = this._exclusions?.noEffect ?? [];
const unplaced = this._unplacedExclusions(
this.value?.exclude,
this._exclusions
);
const perTarget = this._exclusionsPerTarget();
if (
!entityIds.length &&
@@ -467,6 +497,25 @@ 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()} `;
}
@@ -563,13 +612,155 @@ 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, so it only offers what would take something out.
private async _openExcludePicker(ev: Event) {
this._excludePicker?.open(ev);
if (!this._exclusions.includedEntities) {
await this._exclusions.resolve({ force: true });
// 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) {
this._excludePicker?.open(ev);
if (!this._includedEntities) {
this._resolveIncludedEntities();
}
}
@@ -600,17 +791,27 @@ 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._exclusions.perTarget;
const perTarget = this._exclusionsPerTarget();
const exclusions = perTarget ? (perTarget[key] ?? []) : undefined;
const editable =
exclusions &&
this.allowExclude &&
!this.disabled &&
this._exclusions.complete;
this._exclusionsComplete;
showTargetDetailsDialog(this, {
title,
type,
@@ -623,10 +824,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
primaryEntitiesOnly: this.primaryEntitiesOnly,
exclusions,
exclude: this.value?.exclude,
expandGroup: this._exclusions.expandGroup,
explainExclusion: editable
? (item) => this._exclusions.explainExclusion(key, item)
: undefined,
otherTargetEntities: Object.entries(
this._exclusions?.included ?? {}
).flatMap(([targetKey, entities]) => (targetKey === key ? [] : entities)),
exclusionsChanged: editable
? (initial, current) => {
fireEvent(this, "value-changed", {
@@ -688,10 +888,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return this._getItemsMemoized(
this.hass.localize,
this._excludeEntityFilter(
this.entityFilter,
this._exclusions.includedEntities
),
this._excludeEntityFilter(this.entityFilter, this._includedEntities),
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
@@ -155,7 +155,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
}
.pane .footer {
border-top: 1px solid var(--divider-color);
padding-bottom: 8px;
}
+15 -1
View File
@@ -1,4 +1,4 @@
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
@@ -70,6 +70,20 @@ export class HaListItemBase extends HaRowItem {
}
}
protected updated(changed: PropertyValues<this>): void {
super.updated(changed);
// The list only reads focusability on (un)registration, so tell it when
// the row starts or stops taking focus later on
if (
this._list &&
((changed.has("disabled") && changed.get("disabled") !== undefined) ||
(changed.has("interactive") &&
changed.get("interactive") !== undefined))
) {
this._list.requestListItemsUpdate();
}
}
/**
* Activate the item (Enter/Space from the parent list). Default dispatches
* a click on the host. Subclasses that wrap a native element (e.g. `<a>`)
+22 -5
View File
@@ -71,6 +71,8 @@ export class HaListBase extends LitElement {
private _unbindKeys?: () => void;
private _listItemsUpdatePending = false;
public connectedCallback() {
super.connectedCallback();
if (!this.hasAttribute("ha-list")) {
@@ -170,6 +172,21 @@ export class HaListBase extends LitElement {
this.applyActive(focusItem);
}
/**
* Run `updateListItems()` once after the current task, so rows that change
* their focusability together cause a single update.
*/
public requestListItemsUpdate() {
if (this._listItemsUpdatePending) {
return;
}
this._listItemsUpdatePending = true;
queueMicrotask(() => {
this._listItemsUpdatePending = false;
this.updateListItems();
});
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
@@ -191,12 +208,12 @@ export class HaListBase extends LitElement {
) {
this.activeItemIndex = -1;
}
} else if (
this.activeItemIndex >= this.itemCount ||
!this.hasFocusableItem ||
this.activeItemIndex < 0
) {
} else if (!this.isFocusable(this.activeItemIndex)) {
// Also leave an active row that got disabled, so the list keeps a Tab stop
this.activeItemIndex = this.firstFocusableIndex;
if (activeIndex !== -1) {
this._activeItem = this.items[this.activeItemIndex];
}
}
if (!this._activeItem) {
this._activeItem = this.items[this.activeItemIndex];
+61 -8
View File
@@ -85,17 +85,21 @@ export class HaListVirtualized extends HaListBase {
protected rangeStart = -1;
protected rangeEnd = -1;
private _visibleStart = -1;
private _visibleEnd = -1;
private _activeItemFocus = false;
private _scrollToActiveItem = false;
public willUpdate(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues<this>) {
if (!this.hasUpdated) {
this._loadVirtualizer();
}
if (changedProps.has("rows")) {
this.recomputeFocusableIndexes();
this.activeItemIndex = this.virtualFocus ? -1 : this.firstFocusableIndex;
this.activeItemIndex = this.virtualFocus
? -1
: this._followActiveRow(changedProps.get("rows"));
}
}
@@ -104,6 +108,28 @@ export class HaListVirtualized extends HaListBase {
return this.virtualizerElement ?? undefined;
}
/**
* Render the rows in view again. Call it when `rowRenderer` reads state
* that is not part of `rows`, like the selection.
*/
public requestRowsUpdate() {
this.virtualizerElement?.requestUpdate();
}
// Keep the active row if the list has focus, else reset to the first row
private _followActiveRow(previousRows?: HaListVirtualizedItem[]): number {
const activeId = this.matches(":focus-within")
? previousRows?.[this.activeItemIndex]?.id
: undefined;
const index =
activeId === undefined
? -1
: this.rows.findIndex((row) => row.id === activeId);
return index !== -1 && this.isFocusable(index)
? index
: this.firstFocusableIndex;
}
/** Scroll the row at `index` into view. */
public scrollToIndex(
index: number,
@@ -196,20 +222,41 @@ export class HaListVirtualized extends HaListBase {
return;
}
if (this.virtualizerElement && this.rangeStart > -1) {
const tabStopIndex = this._tabStopIndex();
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
if (index + this.rangeStart === this.activeItemIndex) {
el.tabIndex = 0;
if (focusItem) {
el.focus();
}
} else {
const rowIndex = index + this.rangeStart;
// Like the base list, keep other rows focusable by click
if (!this.isFocusable(rowIndex)) {
el.removeAttribute("tabindex");
return;
}
el.tabIndex = rowIndex === tabStopIndex ? 0 : -1;
if (focusItem && rowIndex === this.activeItemIndex) {
el.focus();
}
});
}
}
// The row that takes Tab: the active row while it is visible, else the
// first visible row, so Tab never lands on a row out of view
private _tabStopIndex(): number {
const [start, end] =
this._visibleStart > -1
? [this._visibleStart, this._visibleEnd]
: [this.rangeStart, this.rangeEnd];
if (this.activeItemIndex >= start && this.activeItemIndex <= end) {
return this.activeItemIndex;
}
for (let i = start; i <= end; i++) {
if (this.isFocusable(i)) {
return i;
}
}
return -1;
}
private _applyVirtualActive(reveal: boolean) {
if (!this.virtualizerElement || this.rangeStart < 0) {
return;
@@ -227,6 +274,12 @@ export class HaListVirtualized extends HaListBase {
@eventOptions({ passive: true })
private _handleVisibilityChanged(ev: { first: number; last: number }) {
this._visibleStart = ev.first;
this._visibleEnd = ev.last;
if (!this.virtualFocus) {
this.applyActive(false);
}
fireEvent(this, "ha-list-visibility-changed", {
first: ev.first,
last: ev.last,
@@ -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 });
};
@@ -30,15 +30,14 @@ import {
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import type { HomeAssistantConfig } from "../../types";
import "../ha-button";
import "../ha-check-list-item";
import "../ha-dialog";
import "../ha-dialog-footer";
import "../ha-dialog-header";
import "../ha-icon-button";
import "../ha-list";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tip";
import "../item/ha-list-item-option";
import "../list/ha-list-selectable";
import "./ha-media-upload-button";
import type { MediaManageDialogParams } from "./show-media-manage-dialog";
@@ -101,12 +100,6 @@ class DialogMediaManage extends LitElement {
}
this._filteredChildren =
this._currentItem?.children?.filter((child) => !child.can_expand) || [];
if (this._filteredChildren.length === 0 && this._selected.size !== 0) {
// When running delete all, sometimes the list can throw off a spurious
// select event that makes it think that 1 item is still selected. Clear selected
// if nothing can be selected.
this._selected = new Set();
}
}
protected render() {
@@ -218,14 +211,21 @@ class DialogMediaManage extends LitElement {
)}
</ha-button>
</div>
<ha-list multi @selected=${this._handleSelected}>
<ha-list-selectable
multi
aria-label=${this._localize(
"ui.components.media-browser.file_management.title"
)}
@ha-list-item-selected=${this._handleItemSelected}
@ha-list-item-deselected=${this._handleItemDeselected}
>
${repeat(
this._filteredChildren,
(item) => item.media_content_id,
(item) => {
const icon = html`
<ha-svg-icon
slot="graphic"
slot="start"
.path=${
MediaClassBrowserSettings[
item.media_class === "directory"
@@ -237,22 +237,23 @@ class DialogMediaManage extends LitElement {
></ha-svg-icon>
`;
return html`
<ha-check-list-item
<ha-list-item-option
${animate({
id: item.media_content_id,
skipInitial: true,
})}
graphic="icon"
appearance="checkbox"
selection-position="end"
.disabled=${this._uploading || this._deleting}
.selected=${this._selected.has(fileIndex++)}
.item=${item}
>
${icon} ${item.title}
</ha-check-list-item>
${icon}
<span slot="headline">${item.title}</span>
</ha-list-item-option>
`;
}
)}
</ha-list>
</ha-list-selectable>
`
}
${
@@ -278,8 +279,14 @@ class DialogMediaManage extends LitElement {
`;
}
private _handleSelected(ev) {
this._selected = ev.detail.index;
private _handleItemSelected(ev: CustomEvent<number>) {
this._selected = new Set(this._selected).add(ev.detail);
}
private _handleItemDeselected(ev: CustomEvent<number>) {
const selected = new Set(this._selected);
selected.delete(ev.detail);
this._selected = selected;
}
private _startUploading() {
+11 -4
View File
@@ -14,8 +14,9 @@ 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. Like the data table's loading rows, the label is given to assistive
* technology rather than shown.
* 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)`.
*/
@@ -23,7 +24,9 @@ const LINE_HEIGHT_EM = 1.5;
export class HaSkeletonLog extends LitElement {
@property() public label?: string;
private _lineCount = new ResizeController(this, {
@property({ type: Number, reflect: true }) public lines?: number;
private _fitLineCount = new ResizeController(this, {
callback: (entries) => {
const height = entries[0]?.contentRect.height ?? 0;
@@ -39,7 +42,7 @@ export class HaSkeletonLog extends LitElement {
<ha-fade-in .duration=${300} easing="ease-in">
<div role="progressbar" aria-label=${ifDefined(this.label)}>
${Array.from(
{ length: this._lineCount.value ?? 0 },
{ length: this.lines ?? this._fitLineCount.value ?? 0 },
(_, i) =>
html`<div class="line">
<ha-skeleton-text
@@ -62,6 +65,10 @@ export class HaSkeletonLog extends LitElement {
padding: var(--ha-skeleton-log-padding, var(--ha-space-2));
overflow: hidden;
}
:host([lines]) {
display: block;
position: static;
}
.line {
display: flex;
align-items: center;
@@ -28,7 +28,12 @@ import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetExclusion } from "../target-exclusions";
import { resolveItemExclusions, toggleTargetRow } from "../target-exclusions";
import {
isShared,
resolveItemExclusions,
resolveTargetItemEntities,
toggleTargetRow,
} from "../target-exclusions";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
@@ -49,6 +54,10 @@ 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))
@@ -74,8 +83,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
params.exclude,
params.selector
? params.selector.target?.primary_entities_only
: params.primaryEntitiesOnly,
params.expandGroup
: params.primaryEntitiesOnly
);
} catch (_err) {
// The details still show the target, only without what is excluded.
@@ -284,32 +292,37 @@ 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._explainExclusion(exclusion));
.forEach((exclusion) => this._resolveRemoved(exclusion));
}
private async _explainExclusion(exclusion: TargetExclusion) {
const explain = this._params?.explainExclusion;
if (!explain) {
return;
}
let explained: TargetExclusion | undefined;
private async _resolveRemoved(exclusion: TargetExclusion) {
let removed: string[];
try {
explained = await explain({ type: exclusion.type, id: exclusion.id });
removed = await resolveTargetItemEntities(this.hass, exclusion, false);
} catch (_err) {
return;
}
if (!explained || !this._exclusions.includes(exclusion)) {
if (!this._exclusions.includes(exclusion)) {
return;
}
const shared = isShared(
removed,
this._otherTargetEntities(this._params?.otherTargetEntities)
);
this._exclusions = this._exclusions.map((current) =>
current === exclusion ? explained : current
current === exclusion ? { ...exclusion, removed, shared } : 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 { TargetItem, TargetType } from "../../../data/target";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import type { TargetExclusion } from "../target-exclusions";
@@ -25,16 +25,10 @@ export interface TargetDetailsDialogParams {
*/
exclude?: HassServiceTarget;
/**
* 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`.
* The entities of the other included targets, so an exclusion made here
* is known to also affect them.
*/
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>;
otherTargetEntities?: string[];
/**
* Set to give the rows checkboxes. Called on apply with the exclusions the
* dialog opened with and the ones it has now.
@@ -1,5 +1,4 @@
import {
mdiChartLine,
mdiClose,
mdiDevices,
mdiHome,
@@ -58,11 +57,6 @@ 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,
@@ -183,9 +177,6 @@ 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;
@@ -782,19 +773,6 @@ 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 {
@@ -881,15 +859,6 @@ 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(
{
@@ -1,224 +0,0 @@
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))
);
}
);
}
@@ -1,11 +1,9 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../common/array/ensure-array";
import {
explainTarget,
extractFromTarget,
type ExtractFromTargetResultReferenced,
type HassServiceTargetWithExclude,
type TargetExplanation,
type TargetIncludeEntities,
type TargetItem,
type TargetType,
} from "../../data/target";
@@ -36,6 +34,30 @@ export const targetItems = (target?: HassServiceTarget): TargetItem[] =>
export const hasIncludedTargets = (target?: HassServiceTarget): boolean =>
targetItems(target).length > 0;
/** A target item with the entities it resolves to. */
export interface ResolvedTargetItem extends TargetItem {
entities: string[];
}
/** The entities a single target item resolves to. */
export const resolveTargetItemEntities = async (
hass: HomeAssistant,
item: TargetItem,
primaryEntitiesOnly?: boolean
): Promise<string[]> => {
if (item.type === "entity") {
return item.id === "all" ? Object.keys(hass.states) : [item.id];
}
return (
await extractFromTarget(
hass.callWS,
{ [`${item.type}_id`]: [item.id] },
false,
primaryEntitiesOnly
)
).referenced_entities;
};
/** An exclusion as seen from one included target. */
export interface TargetExclusion extends TargetItem {
/** Entities it takes out of that target. */
@@ -54,42 +76,26 @@ export interface TargetExclusions {
}
/**
* The entities the picker's filters leave in an included target other than
* an entity. An entity item is what it is, filters or not.
*/
export type TargetNarrowing = (include: TargetIncludeEntities) => string[];
/**
* Which exclusions take what out of each included target, from what core
* explained. Excludes are global, so one exclusion is listed under every
* target it affects; one that affects none, including one whose item no
* longer exists, has no effect. `keeps` narrows each target to what is left
* of it once the picker's filters are applied; what an exclusion takes out
* of it is narrowed the same way.
* Which exclusions take what out of each included target. Excludes are
* global, so one exclusion is listed under every target it affects; one that
* affects none, including one whose item no longer exists, has no effect.
*/
export const computeExclusionsPerTarget = (
explained: TargetExplanation,
keeps?: TargetNarrowing
includes: ResolvedTargetItem[],
excludes: ResolvedTargetItem[]
): TargetExclusions => {
const included = Object.fromEntries(
explained.includes.map((include) => [
targetItemKey(include),
include.type === "entity" || !keeps ? include.entities : keeps(include),
])
);
const perTarget: Record<string, TargetExclusion[]> = {};
const noEffect: TargetItem[] = [];
explained.excludes.forEach((exclude) => {
const hits = exclude.removed
.map((target) => {
const key = targetItemKey(target);
const entities = new Set(included[key] ?? []);
return {
key,
removed: target.entities.filter((entityId) => entities.has(entityId)),
};
})
excludes.forEach((exclude) => {
const excluded = new Set(exclude.entities);
const hits = includes
.map((include) => ({
include,
removed: [...new Set(include.entities)].filter((entityId) =>
excluded.has(entityId)
),
}))
.filter(({ removed }) => removed.length > 0);
if (!hits.length) {
@@ -97,7 +103,8 @@ export const computeExclusionsPerTarget = (
return;
}
hits.forEach(({ key, removed }) => {
hits.forEach(({ include, removed }) => {
const key = targetItemKey(include);
if (!perTarget[key]) {
perTarget[key] = [];
}
@@ -110,58 +117,41 @@ export const computeExclusionsPerTarget = (
});
});
// An action replaces a group it targets directly by its members, but the
// group's row is the group itself. Once every member is out, so is the
// group, and each exclusion counts it like its row does.
explained.includes.forEach((include) => {
const key = targetItemKey(include);
const exclusions = perTarget[key];
const members = included[key];
if (
include.type !== "entity" ||
!exclusions ||
!members.length ||
members.includes(include.id)
) {
return;
}
const removed = new Set(
exclusions.flatMap((exclusion) => exclusion.removed)
);
if (members.every((entityId) => removed.has(entityId))) {
perTarget[key] = exclusions.map((exclusion) => ({
...exclusion,
removed: [include.id],
}));
}
});
const included = Object.fromEntries(
includes.map((include) => [targetItemKey(include), include.entities])
);
return { perTarget, noEffect, included };
};
/**
* The exclusions that take entities out of one item of a target. Actions
* expand the groups a target names, triggers and conditions don't, so
* `expandGroup` follows what the target is used for.
*/
/** The exclusions that take entities out of one item of a target. */
export const resolveItemExclusions = async (
hass: HomeAssistant,
item: TargetItem,
exclude: HassServiceTarget | undefined,
primaryEntitiesOnly?: boolean,
expandGroup = true
primaryEntitiesOnly?: boolean
): Promise<TargetExclusion[]> => {
if (!hasIncludedTargets(exclude)) {
const excludes = targetItems(exclude);
if (!excludes.length) {
return [];
}
const explained = await explainTarget(
hass.callWS,
{ [`${item.type}_id`]: [item.id], exclude },
expandGroup,
primaryEntitiesOnly
);
// Like core, an exclude takes out every entity of an item, not only the
// primary ones.
const [entities, excludeEntities] = await Promise.all([
resolveTargetItemEntities(hass, item, primaryEntitiesOnly),
Promise.all(
excludes.map((exclusion) =>
resolveTargetItemEntities(hass, exclusion, false)
)
),
]);
return (
computeExclusionsPerTarget(explained).perTarget[targetItemKey(item)] ?? []
computeExclusionsPerTarget(
[{ ...item, entities }],
excludes.map((exclusion, index) => ({
...exclusion,
entities: excludeEntities[index],
}))
).perTarget[targetItemKey(item)] ?? []
);
};
@@ -379,9 +369,7 @@ export const targetRowState = (
* excluded as such, including what is added to it later. The target can't
* be excluded from itself, so unchecking it unchecks its rows instead.
* `initial` gives back an exclusion that was there before as it was. A new
* one takes out the row's entities and counts as shared until core tells
* what it takes out of every target, so a parent row can't take it away
* before it is known whether it also affects another target.
* one is shared when it takes out any of `otherTargetEntities`.
*/
export const toggleTargetRow = ({
row,
@@ -389,12 +377,14 @@ export const toggleTargetRow = ({
selected,
exclusions,
initial,
otherTargetEntities,
}: {
row: TargetRow;
isTarget: boolean;
selected: boolean;
exclusions: TargetExclusion[];
initial: TargetExclusion[];
otherTargetEntities?: ReadonlySet<string>;
}): TargetExclusion[] => {
if (!selected && isTarget) {
return row.children
@@ -407,6 +397,7 @@ export const toggleTargetRow = ({
selected: false,
exclusions: result,
initial,
otherTargetEntities,
}),
exclusions
);
@@ -426,11 +417,19 @@ export const toggleTargetRow = ({
type: row.type,
id: row.id,
removed: row.entities,
shared: true,
shared: isShared(row.entities, otherTargetEntities),
},
];
};
/** Whether an exclusion taking out `removed` also affects another target. */
export const isShared = (
removed: string[],
otherTargetEntities?: ReadonlySet<string>
): boolean =>
!!otherTargetEntities &&
removed.some((entityId) => otherTargetEntities.has(entityId));
/**
* The new `exclude` once the details are applied: what was taken away is
* removed, what was added is added, and everything else stays as it was.
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
/**
* Whether the targets below expand the groups they name when they are used.
* Actions do, so that is what holds when nothing provides it. Triggers and
* conditions don't, so their editors provide `false`.
*/
export const targetExpandGroupContext =
createContext<boolean>("targetExpandGroup");
@@ -420,16 +420,11 @@ export class HaTracePathDetails extends LitElement {
return nothing;
}
const targetSpec = this._getTargetSelector(config, type);
// Triggers and conditions, including a condition action, don't expand
// the groups their targets name.
const expandGroup =
type !== "trigger" && type !== "condition" && !("condition" in config);
return html`<div class="targets">
<ha-automation-row-targets
.target=${target}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.size=${size}
.expandGroup=${expandGroup}
interactive
></ha-automation-row-targets>
</div>`;
+7 -16
View File
@@ -1,4 +1,3 @@
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { generateEntityFilter } from "../../common/entity/entity_filter";
import type { HomeAssistant } from "../../types";
@@ -62,26 +61,15 @@ export const AREA_CONTROLS_BUTTONS: Record<
"cover-window": coverButton("window"),
};
export const getAreaEntityIds = (
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areaId: string,
excludeEntities?: string[]
): string[] =>
Object.keys(entities).filter(
(entityId) =>
getEntityAreaId(entityId, entities, devices) === areaId &&
!excludeEntities?.includes(entityId)
);
export const getAreaControlEntities = (
areaEntities: string[],
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
controls: readonly AreaControlDomain[]
controls: readonly AreaControlDomain[],
areaId: string,
excludeEntities: string[] | undefined
): Record<AreaControlDomain, string[]> =>
controls.reduce(
(acc, control) => {
@@ -93,12 +81,15 @@ export const getAreaControlEntities = (
areas,
floors,
{
area: areaId,
entity_category: "none",
...controlButton.filter,
}
);
acc[control] = areaEntities.filter(filter);
acc[control] = Object.keys(entities).filter(
(entityId) => filter(entityId) && !excludeEntities?.includes(entityId)
);
return acc;
},
{} as Record<AreaControlDomain, string[]>
+1 -2
View File
@@ -19,8 +19,7 @@ export interface ESPHomeZWaveProxyCapabilities {
export interface ESPHomeSerialProxy {
name: string;
port_type: ESPHomeSerialPortType | null;
// `null` when no device is present (e.g. unpopulated USB port)
url: string | null;
url: string;
}
export interface ESPHomeDeviceCapabilities {
+3 -30
View File
@@ -13,11 +13,7 @@ import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
import {
isExternalStatistic,
type Statistics,
type StatisticsMetaData,
} from "./recorder";
import type { Statistics } from "./recorder";
const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"];
const NEED_ATTRIBUTE_DOMAINS = [
@@ -521,29 +517,11 @@ const isNumericFromAttributes = (attributes: Record<string, any>) =>
const BLANK_UNIT = " ";
/**
* Attributes for an external statistic, which has no entity. Without them the
* history names the series after its ID and shows it without a unit.
*/
const statisticAttributes = (
metadata: StatisticsMetaData | undefined
): Record<string, string> => {
const attributes: Record<string, string> = {};
if (metadata?.name) {
attributes.friendly_name = metadata.name;
}
if (metadata?.statistics_unit_of_measurement) {
attributes.unit_of_measurement = metadata.statistics_unit_of_measurement;
}
return attributes;
};
export const convertStatisticsToHistory = (
hass: HomeAssistant,
statistics: Statistics,
statisticIds: string[],
splitDeviceClasses = false,
statisticsMetadata?: Record<string, StatisticsMetaData>
splitDeviceClasses = false
): HistoryResult => {
// Maintain the statistic id ordering
const orderedStatistics: Statistics = {};
@@ -556,15 +534,10 @@ export const convertStatisticsToHistory = (
// Convert statistics to HistoryResult format
const statsHistoryStates: HistoryStates = {};
Object.entries(orderedStatistics).forEach(([key, value]) => {
// Other statistics keep a blank unit, so that they merge into the history
// line of their entity even after the entity was removed.
const attributes = isExternalStatistic(key)
? statisticAttributes(statisticsMetadata?.[key])
: {};
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
lc: e.end / 1000,
a: attributes,
a: {},
lu: e.end / 1000,
}));
statsHistoryStates[key] = entityHistoryStates;
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more