Compare commits

..
Author SHA1 Message Date
Bram Kragten a67b691a45 Bumped version to 20261028.4 2026-11-04 12:05:25 +01:00
Franck Nijhof fc0f86e46d Add Marketplace download source menu item for the China mirrors (#55615) 2026-11-04 12:04:21 +01:00
Aidan Timson ef6438a77d Fix the demo embed for the contributors site (#55596) 2026-11-04 12:03:30 +01:00
WendelinandClaude Opus 5.5 7b4981e850 Add descriptive aria-labels to picker filter chips (#55592)
* Give picker section filter chips a descriptive aria-label

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

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

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-04 11:53:19 +01:00
Maarten Lakerveld a46e90de67 Stop recomputing active system domains on every state change in the add element dialog (#55586) 2026-11-04 11:31:19 +01:00
3dfadb7fdf Let core work out what target exclusions take out (#55585)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-04 11:25:33 +01:00
Paul Bottein 6f3ef16d20 Add an option to include pre-releases in Marketplace repositories (#55567) 2026-11-04 11:01:42 +01:00
17d08d26f6 Wrap long descriptions in the entity settings (#55590)
* Wrap supporting text of entity settings rows

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

Fixes #55584

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

* Update supporting-text styles for row and list items

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-03 14:34:40 +01:00
Alexander Hass aa06e8ce25 Fix README images and links with a relative path in the Marketplace (#55525)
* Keep relative README addresses for the Marketplace rewrite

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

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

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

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

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

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

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

* Escape a kept relative README address only once

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

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

* Drop relative README addresses with a scheme and reuse URL_ATTRIBUTES

A relative path never has a scheme, so an address xss empties that still has one after decoding (HTTPS://..., j:alert(1) from &#0000106avascript:) or is empty is dropped instead of being kept below ./, where it would request a path in the repository that does not exist. The worker imports URL_ATTRIBUTES from rewrite-html-urls, so the attributes it keeps and the ones the rewrite touches cannot drift apart.
2026-11-03 14:34:39 +01:00
Bram Kragten 122877cdf5 Bumped version to 20261028.3 2026-11-02 16:42:17 +01:00
Josef Zweck a103eac243 Add access risk to the Marketplace warning (#55559) 2026-11-02 16:41:33 +01:00
Paul Bottein a9a8a9aad9 Show the area first in integration device rows (#55557) 2026-11-02 16:41:32 +01:00
WendelinandClaude Opus 5.5 35a054c1ba 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 16:41:31 +01:00
960e99ed66 Refactor bulk delete labels (#55555)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-02 16:41:29 +01:00
Paul Bottein 232375befb Limit the more info activity to the 100 most recent entries (#55553) 2026-11-02 16:41:28 +01:00
Aidan Timson 91ee29a2e6 Use generic picker for install another version (#55552) 2026-11-02 16:41:26 +01:00
Aidan Timson a6dc5d0008 Shorten the Marketplace description on the settings page (#55549) 2026-11-02 16:41:25 +01:00
Thomas Mueller 7a66de362e 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 16:41:23 +01:00
Thomas Mueller 36500e5b19 Fix media progress slider on Chromium < 125 (#55539)
matches(":state(dragging)") throws before Chromium 125. Use
customStates.has("dragging") instead.
2026-11-02 16:41:22 +01:00
karwosts df88136147 Fix date-only attribute formatting to not shift into other days (#55536) 2026-11-02 16:41:21 +01:00
jimjaeger 2f1d738c2c Fix onboarding using US customary units when the location is detected (#55535)
Keep the detected unit system in onboarding
2026-11-02 16:41:19 +01:00
Paulus SchoutsenandClaude bfeb26b47d 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 16:41:18 +01:00
Joost LekkerkerkerandClaude 8de5bfee89 Stretch chip container to fill host so outline matches host shape (#55510)
Co-authored-by: Claude <[email protected]>
2026-11-02 16:41:17 +01:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 1efc7f8a95 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-11-02 16:41:15 +01:00
Erwin DounaandAidan Timson 9e0d38fba8 Show a skeleton while the live log loads (#55443)
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 16:41:13 +01:00
2932f8f528 Add embeddable dashboard card components to the demo (#54348)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Aidan Timson <[email protected]>
2026-11-02 16:41:12 +01:00
Bram Kragten 6a0902d18c Bumped version to 20261028.2 2026-10-30 23:57:41 +01:00
Paul Bottein 4424f98eb6 Add translation for the Marketplace panel title (#55486) 2026-10-30 23:57:29 +01:00
Maarten Lakerveld ea13594fb0 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 23:57:28 +01:00
Daan Vervacke ce726a8a3f Wrap long title in YAML-only integration dialog (#55481) 2026-10-30 23:57:26 +01:00
Paul Bottein d67479da6d Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-30 23:57:25 +01:00
Bram Kragten 275e30879c Bumped version to 20261028.1 2026-10-29 16:44:20 +01:00
Maarten Lakerveld 7b53005865 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 16:43:58 +01:00
Paul Bottein 94950fcacd Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 16:43:56 +01:00
Petar Petrov 4214fe46c6 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 16:43:55 +01:00
Aidan Timson d6325eef7d 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 16:43:54 +01:00
WendelinandClaude Opus 5.5 12e722f122 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 16:43:53 +01:00
Paul Bottein 83eb3ca13a Use the state color by default in alert cards and badges (#55436) 2026-10-29 16:43:51 +01:00
Petar Petrov 0532b1f5f6 Ignore YAML dashboard update event on first load (#55430) 2026-10-29 16:43:50 +01:00
Tristan Cartledge 970afb588d Fix duplicated hidden areas when reordering areas in the areas dashboard editor (#55424) 2026-10-29 16:43:49 +01:00
Maarten Lakerveld 23137ce6d2 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-29 16:43:48 +01:00
JonathanandXyolyp 6b022d63e8 Preselect the only allowed unit also in an optional unit selector (#55417)
Co-authored-by: Xyolyp <[email protected]>
2026-10-29 16:43:46 +01:00
Paul Bottein d3c6e132b7 Show multi-select chips in a field with an inline add button (#55416) 2026-10-29 16:43:45 +01:00
Paul Bottein c08b261d4b Keep the section row divider under the entities card header (#55415) 2026-10-29 16:43:44 +01:00
Bram KragtenandClaude Opus 5.5 0721b22036 Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 16:43:43 +01:00
392 changed files with 5243 additions and 15830 deletions

No files matched your search

+1 -31
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, loading states, 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, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
@@ -91,36 +91,6 @@ html`
`;
```
## Loading States
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
- Don't fade in the loaded content itself.
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
```ts
html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
role="progressbar"
aria-label=${this._i18n.localize("ui.common.loading")}
>
<ha-skeleton-text></ha-skeleton-text>
</div>
</ha-fade-in>
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
`;
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
-4
View File
@@ -12,7 +12,3 @@
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
# on a conflict, restore it from the base branch and run `gh actions-lock`.
.github/workflows/actions.lock linguist-language=YAML -merge
+3 -5
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to backend pull request:
## Checklist
@@ -63,8 +63,7 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
understanding the code before submitting a PR.
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -106,5 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
-6
View File
@@ -1,6 +0,0 @@
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
# https://github.com/rhysd/actionlint/issues/711
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
@@ -24,7 +24,7 @@ runs:
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
cache: false
-41
View File
@@ -1,41 +0,0 @@
name: Verify actions lockfile
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
name: Verify actions lockfile
runs-on: ubuntu-latest
env:
GH_ACTIONS_LOCK_VERSION: v0.4.1
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
- name: Verify lockfile
env:
GH_TOKEN: ${{ github.token }}
run: gh actions-lock --verify
-190
View File
@@ -1,190 +0,0 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/actionlint.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/actions-lock.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/cast_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/ci.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/codeql-analysis.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
'.github/workflows/copilot-setup-steps.yml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/demo_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/dependency-review.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/design_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/design_preview.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/e2e.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/labeler.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
'.github/workflows/lock.yaml':
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
'.github/workflows/nightly.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/pull-request-standards.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/relative-ci.yaml':
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
'.github/workflows/release-drafter.yaml':
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
'.github/workflows/release.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
'.github/workflows/restrict-task-creation.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/stale.yaml':
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
'.github/workflows/sync-device-classes.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/sync-sensor-entity-constants.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/translations.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
dependencies:
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
ref: 'v5.0.0'
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
owner_id: 44036562
repo_id: 476372928
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
ref: 'v9.0.0'
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
owner_id: 44036562
repo_id: 205262760
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
ref: 'v7.0.0'
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
owner_id: 44036562
repo_id: 177139928
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
ref: 'v7.1.0'
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
owner_id: 44036562
repo_id: 192625525
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
ref: 'v6.2.0'
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
owner_id: 44036562
repo_id: 192625525
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
ref: 'v11.0.0'
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
owner_id: 44036562
repo_id: 200437066
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
ref: 'v6.0.2'
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
owner_id: 7513068
repo_id: 113918130
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
ref: 'v4.39.1'
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
owner_id: 9919
repo_id: 259445878
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
ref: 'master'
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
owner_id: 13844975
repo_id: 173703505
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
ref: '2026.07.0'
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
owner_id: 13844975
repo_id: 184234812
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
ref: 'v8.1.1'
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
owner_id: 18365890
repo_id: 197176365
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
ref: 'v1.14.3'
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
owner_id: 647025
repo_id: 178055147
uses:
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
ref: 'v3.2.4'
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
owner_id: 43522883
repo_id: 398376354
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
ref: 'v7.10.0'
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
owner_id: 58292376
repo_id: 139022359
+8 -8
View File
@@ -18,7 +18,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Deploy Development
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -30,17 +30,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Cast
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Deploy Production
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Cast
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
lint:
name: Lint and check format
@@ -43,7 +43,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
@@ -89,7 +89,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
@@ -110,7 +110,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
@@ -127,7 +127,7 @@ jobs:
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:javascript-typescript"
+3 -38
View File
@@ -22,45 +22,10 @@ jobs:
with:
persist-credentials: false
# These steps mirror .github/actions/setup with node-modules-cache enabled.
# Copilot runs this workflow in a context that can't resolve `$/`
# self-repository actions, so the steps are inlined here.
- name: Restore complete dependency tree
id: dependency-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
path: |
node_modules
~/.local/share/pnpm/store
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
id: pnpm-store
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.pnpm-store.outputs.path
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
+8 -8
View File
@@ -19,7 +19,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Demo Development
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.unique_deploy_url }}
@@ -31,17 +31,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Demo Production
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -25,17 +25,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -29,17 +29,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
+16 -16
View File
@@ -32,13 +32,13 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
defaults:
run:
@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -97,12 +97,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -128,12 +128,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -176,7 +176,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -230,7 +230,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -326,7 +326,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
@@ -124,7 +124,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
+5 -5
View File
@@ -31,15 +31,15 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
@@ -85,7 +85,7 @@ jobs:
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -146,7 +146,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
+1 -1
View File
@@ -1 +1 @@
24.22.2
24.22.1
+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, loading states, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-components`: dialogs, forms, alerts, 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.
+50 -42
View File
@@ -1,7 +1,8 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -13,14 +14,12 @@ import {
enableWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { atLeastVersion } from "../../../../src/common/config/version";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-selectable";
import {
getLegacyLovelaceCollection,
getLovelaceCollection,
@@ -54,9 +53,6 @@ class HcCast extends LitElement {
`
: undefined;
const views = this.lovelaceViews ?? [{ title: "Home" }];
const hasIcons = views.some((view) => view.icon);
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
@@ -97,41 +93,45 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
view.icon
? html`
<ha-icon
.icon=${view.icon}
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item-option>
</ha-list-item>
`
)}
</ha-list-selectable>
)}</ha-list
>
`
}
@@ -194,6 +194,14 @@ class HcCast extends LitElement {
});
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
@@ -207,13 +215,8 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
// for the selected view, so the current view can still be cast again.
private async _handlePickView(
ev: HASSDomCurrentTargetEvent<HaListItemOption>
) {
const index = Number(ev.currentTarget.dataset.index);
const path = this.lovelaceViews?.[index]?.path ?? index;
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
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);
}
@@ -268,11 +271,16 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
-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>`;
}
}
-3
View File
@@ -4,9 +4,6 @@ import "./workers";
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
+141 -22
View File
@@ -1,44 +1,139 @@
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
private _card = createRef<HuiCard>();
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
}
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
}
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>`;
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card
${ref(this._card)}
.hass=${this._hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>
</div>`;
}
protected updated() {
@@ -68,7 +163,7 @@ export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
};
static styles = [
embedElementStyles,
embedThemeStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
@@ -81,6 +176,9 @@ export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
:host([full-width]) {
grid-column: 1 / -1;
}
:host([hidden]) {
display: none;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
@@ -88,6 +186,27 @@ export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
)
);
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
-173
View File
@@ -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;
}
}
+11 -35
View File
@@ -8,7 +8,6 @@ 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";
@@ -23,8 +22,6 @@ import {
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
@@ -43,7 +40,6 @@ import { readScriptData } from "./script-data";
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
devices?: DemoDevice[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
@@ -58,8 +54,8 @@ const areaName = (areaId: string) => {
};
/**
* Holds a mocked Home Assistant backend for all embed elements inside it.
* Elements in the same provider share their entities.
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@@ -71,19 +67,13 @@ export class HaEmbedProvider extends LitElement {
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `devices`, `calendars` and `energy`.
* `areas`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
/** All areas. Without them, an area for each `area_id` of the entities. */
@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.
@@ -165,9 +155,6 @@ export class HaEmbedProvider extends LitElement {
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, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
@@ -235,7 +222,6 @@ export class HaEmbedProvider extends LitElement {
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.devices = data.devices;
this.calendars = data.calendars;
this.energy = data.energy;
}
@@ -252,19 +238,14 @@ export class HaEmbedProvider extends LitElement {
this._applyColorScheme();
}
if (
changedProps.has("entities") ||
changedProps.has("areas") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
if (changedProps.has("entities") || changedProps.has("areas")) {
const areas: DemoArea[] =
this.areas ??
[
...new Set(
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
@@ -272,16 +253,11 @@ export class HaEmbedProvider extends LitElement {
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 });
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
// Entities that services changed keep their state when only the areas
// or devices change
// change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
-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);
}
`,
];
}
-14
View File
@@ -15,17 +15,3 @@ export const readScriptData = (element: HTMLElement): unknown => {
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
+1 -35
View File
@@ -1,4 +1,4 @@
import { css, unsafeCSS } from "lit";
import { unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
@@ -59,37 +59,3 @@ 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;
}
`,
];
+9 -291
View File
@@ -108,24 +108,15 @@
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and its
entities. These components render dashboard content with the backend of
a provider:
</p>
<p>There are two components:</p>
<ul>
<li><code>&lt;ha-embed-card&gt;</code> renders one card.</li>
<li>
<code>&lt;ha-embed-section&gt;</code> renders one section, or a
section strategy.
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
</li>
<li>
<code>&lt;ha-embed-view&gt;</code> renders one view, a view strategy,
or one view of a dashboard.
</li>
<li>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard, or a
dashboard strategy, with its tabs.
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
</li>
</ul>
<p>
@@ -333,193 +324,6 @@
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<p>
The other components find their provider the same way, and take the
same <code>provider</code> attribute.
</p>
<h2>Sections</h2>
<p>
<code>&lt;ha-embed-section&gt;</code> renders a section the same way as
in a sections view. The configuration is the same as in a dashboard,
so headings span the top, and a <code>strategy</code> generates the
section. The section takes the width of the component.
</p>
<pre><code>&lt;ha-embed-section&gt;
&lt;script type="text/yaml"&gt;
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen
- type: tile
entity: switch.kettle
&lt;/script&gt;
&lt;/ha-embed-section&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
entities:
- entity_id: light.kitchen_ceiling
state: "on"
device_id: kitchen_ceiling
attributes:
friendly_name: Ceiling
supported_color_modes: [brightness]
color_mode: brightness
brightness: 200
- entity_id: switch.kettle
state: "off"
area_id: kitchen
attributes:
friendly_name: Kettle
device_class: outlet
- entity_id: sensor.kitchen_temperature
state: "21.4"
area_id: kitchen
attributes:
friendly_name: Kitchen temperature
device_class: temperature
state_class: measurement
unit_of_measurement: °C
devices:
- id: kitchen_ceiling
name: Kitchen ceiling light
manufacturer: Acme
model: Smart bulb
area_id: kitchen
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-section>
<!-- htmlmin:ignore --><script type="text/yaml">
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen_ceiling
tap_action:
action: toggle
- type: tile
entity: switch.kettle
tap_action:
action: toggle
- type: tile
entity: sensor.kitchen_temperature
grid_options:
columns: 12
</script><!-- htmlmin:ignore -->
</ha-embed-section>
</div>
</ha-embed-provider>
<h2>Views</h2>
<p>
<code>&lt;ha-embed-view&gt;</code> renders a view, with its badges and
layout. A <code>strategy</code> generates the view. Give the component
the width the view needs: masonry and sidebar views pick their columns
from the width of the page, not the component.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
type: sections
badges:
- entity: sensor.outside_temperature
sections:
- cards: …
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<p>
To show one view of a dashboard, or of a dashboard strategy, put the
dashboard in <code>dashboard</code> and the path or index of the view
in <code>view</code>. Without <code>view</code>, the first view shows.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
dashboard:
strategy:
type: custom:my-dashboard
view: overview
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<ha-embed-view provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: sections
max_columns: 2
badges:
- entity: sensor.outside_temperature
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Hallway
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-view>
<h2>Dashboards</h2>
<p>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard with its
tabs. A <code>strategy</code> generates the dashboard. Each dashboard
keeps its own tab, so switching tabs does not change the address of
your page. The dashboard scrolls inside the component, under its
toolbar. The component is 600 pixels high; set its
<code>height</code> to change that. Set <code>view</code> to the path
or index of the first view to show.
</p>
<pre><code>&lt;ha-embed-dashboard style="height: 480px"&gt;
&lt;script type="text/yaml"&gt;
views:
- title: Home
path: home
cards: …
- title: Climate
path: climate
cards: …
&lt;/script&gt;
&lt;/ha-embed-dashboard&gt;</code></pre>
<ha-embed-dashboard provider="shared" style="height: 480px">
<!-- htmlmin:ignore --><script type="text/yaml">
views:
- title: Home
path: home
type: sections
max_columns: 2
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Outside
- type: tile
entity: sensor.outside_temperature
- title: Climate
path: climate
type: sections
sections:
- cards:
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-dashboard>
<h2>Light and dark mode</h2>
<p>
@@ -571,7 +375,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<code>provider.hass.states</code>.
</p>
<h2>Areas, devices, calendars and energy</h2>
<h2>Areas, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
@@ -581,15 +385,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities and devices becomes an area.
</li>
<li>
<code>devices</code>: a list of devices, each with an
<code>id</code> and a <code>name</code>, and optionally a
<code>manufacturer</code>, <code>model</code> and
<code>area_id</code>. Entities belong to a device with their
<code>device_id</code>, and cards and strategies then find the
device and its name.
<code>area_id</code> of the entities becomes an area.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
@@ -883,92 +679,14 @@ customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pr
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page. The other
components take it too.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-section&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The section configuration or section strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-view&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The view configuration or view strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
<tr>
<td><code>dashboard</code></td>
<td>Attribute and property</td>
<td>
A dashboard configuration or dashboard strategy, to show one of its
views instead of <code>config</code>. A script child with a
<code>dashboard</code> key, and optionally <code>view</code>, sets
it.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the view of <code>dashboard</code> to show.
Without it, the first view.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-dashboard&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The dashboard configuration or dashboard strategy. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the first view to show. Without it, the first
view.
provider around it, else the first provider on the page.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing. Dashboards can't be edited,
and their toolbar only shows their tabs.
such as <code>more-info</code>, do nothing.
</p>
</main>
<script>
@@ -59,10 +59,6 @@ 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,13 +42,7 @@ const REGISTRY_ENTRIES = [
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [
manifest("radio_frequency", "Radio frequency", {
config_flow: false,
integration_type: "entity",
}),
manifest("esphome", "ESPHome", { integration_type: "device" }),
],
manifests: [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[] = [
{
-4
View File
@@ -81,10 +81,6 @@ export default [
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Charts",
pages: ["state-history-charts", "statistics-chart"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
-30
View File
@@ -48,36 +48,6 @@ 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,18 +50,6 @@ 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
@@ -91,7 +79,6 @@ 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,9 +1,7 @@
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";
@@ -21,26 +19,13 @@ 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: 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 localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
+2 -7
View File
@@ -683,13 +683,8 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
name: "Device class",
selector: { device_class: { domain: "sensor" } },
},
sensor_state_class: {
name: "State class",
@@ -1,6 +0,0 @@
---
title: History charts
subtitle: State history as timelines and line charts.
---
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
@@ -1,211 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/state-history-charts";
import "../../../../src/components/ha-card";
import type {
HistoryResult,
LineChartState,
TimelineState,
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const ENTITIES = [
{
entity_id: "sensor.outdoor_temperature",
state: "12.4",
attributes: {
friendly_name: "Outdoor temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
{
entity_id: "light.living_room",
state: "on",
attributes: { friendly_name: "Living room light" },
},
{
entity_id: "binary_sensor.front_door",
state: "off",
attributes: { friendly_name: "Front door", device_class: "door" },
},
];
const temperatureStates = (): LineChartState[] =>
Array.from({ length: HOURS * 2 }, (_, index) => {
const time = START.getTime() + (index * HOUR) / 2;
const hourOfDay = new Date(time).getHours();
const value =
10 +
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.5 * Math.sin(time / HOUR);
return {
state: value.toFixed(1),
last_changed: time,
};
});
const toggleStates = (
onHours: [number, number][],
onLabel: string,
offLabel: string
): TimelineState[] => {
const on = { state: "on", state_localize: onLabel };
const off = { state: "off", state_localize: offLabel };
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
onHours.forEach(([from, to]) => {
states.push(
{ ...on, last_changed: START.getTime() + from * HOUR },
{ ...off, last_changed: START.getTime() + to * HOUR }
);
});
return states;
};
const HISTORY: HistoryResult = {
line: [
{
unit: "°C",
device_class: "temperature",
identifier: "temperature",
data: [
{
domain: "sensor",
name: "Outdoor temperature",
entity_id: "sensor.outdoor_temperature",
states: temperatureStates(),
},
],
},
],
timeline: [
{
name: "Living room light",
entity_id: "light.living_room",
data: toggleStates(
[
[6, 8],
[18, 23],
[30, 32],
[42, 47],
],
"On",
"Off"
),
},
{
name: "Front door",
entity_id: "binary_sensor.front_door",
data: toggleStates(
[
[7.5, 7.6],
[17, 17.2],
[31.5, 31.6],
[41, 41.1],
],
"Open",
"Closed"
),
},
],
};
const LIGHT_ONLY: HistoryResult = {
line: [],
timeline: [HISTORY.timeline[0]],
};
interface Example {
title: string;
description: string;
history: HistoryResult;
showNames?: boolean;
insideLabels?: boolean;
syncCharts?: boolean;
}
const EXAMPLES: Example[] = [
{
title: "Names in a column",
description:
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
history: HISTORY,
},
{
title: "Names inside the timeline, synced zoom",
description:
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
history: HISTORY,
insideLabels: true,
syncCharts: true,
},
{
title: "Single entity without names",
description:
"Like the more-info dialog, where the entity is already known.",
history: LIGHT_ONLY,
showNames: false,
},
];
@customElement("demo-components-state-history-charts")
export class DemoStateHistoryCharts extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<state-history-charts
.historyData=${example.history}
.startTime=${START}
.endTime=${END}
.showNames=${example.showNames ?? true}
.insideLabels=${example.insideLabels ?? false}
.syncCharts=${example.syncCharts ?? false}
.clickForMoreInfo=${false}
></state-history-charts>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-state-history-charts": DemoStateHistoryCharts;
}
}
@@ -1,6 +0,0 @@
---
title: Statistics chart
subtitle: Long-term statistics as a bar or line chart.
---
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
@@ -1,212 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-card";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
StatisticValue,
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const round = (value: number, digits: number) =>
Math.round(value * 10 ** digits) / 10 ** digits;
const hourlyValues = (
compute: (start: number) => Partial<StatisticValue>
): StatisticValue[] =>
Array.from({ length: HOURS }, (_, hour) => {
const start = START.getTime() + hour * HOUR;
return { start, end: start + HOUR, ...compute(start) };
});
const energyValues = (dayUse: number, eveningUse: number) => {
let sum = 0;
return hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
sum += change;
return { change, sum: round(sum, 2) };
});
};
const temperatureValues = (average: number, swing: number) =>
hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const mean =
average +
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.3 * Math.sin(start / HOUR);
return {
mean: round(mean, 1),
min: round(mean - 0.4, 1),
max: round(mean + 0.4, 1),
};
});
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
const LIGHTING_ID = "sensor.lighting_energy";
const LIVING_ROOM_ID = "sensor.living_room_temperature";
const BEDROOM_ID = "sensor.bedroom_temperature";
const HEAT_PUMP = energyValues(0.4, 1.2);
const LIGHTING = energyValues(0.05, 0.3);
const LIVING_ROOM = temperatureValues(21, 1.5);
const BEDROOM = temperatureValues(18.5, 1);
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "kWh",
source: "recorder",
name,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
});
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "°C",
source: "recorder",
name,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
});
const METADATA: Record<string, StatisticsMetaData> = {
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
};
const NAMES = Object.fromEntries(
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
);
interface Example {
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
unit: string;
hideLegend?: boolean;
height?: string;
}
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
description:
"The change of a sum for each hour, like an energy statistics graph card.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
},
{
title: "Stacked bars",
description: "Several sums stacked, to compare what each one adds.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
statTypes: ["change"],
chartType: "bar-stack",
unit: "kWh",
},
{
title: "Mean, min and max",
description: "A measurement with its hourly mean, min and max.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
},
{
title: "Several statistics",
description: "The mean of several measurements with the same unit.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
statTypes: ["mean"],
chartType: "line",
unit: "°C",
},
{
title: "Without legend",
description:
"A single statistic with a fixed height, like in the more-info dialog.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
hideLegend: true,
height: "240px",
},
];
@customElement("demo-components-statistics-chart")
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.hideLegend=${example.hideLegend ?? false}
.height=${example.height}
.clickForMoreInfo=${false}
></statistics-chart>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-statistics-chart": DemoStatisticsChart;
}
}
@@ -25,13 +25,6 @@ const CONFIGS = [
entity: "media_player.music_playing",
},
},
{
heading: "Playing Music (all features)",
config: {
type: "media-control",
entity: "media_player.music_all_features",
},
},
{
heading: "Playing Stream",
config: {
-114
View File
@@ -201,50 +201,6 @@ const ENTITIES = [
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.on_off_fan",
state: "on",
attributes: {
friendly_name: "Bathroom fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.six_speed_fan",
state: "on",
attributes: {
friendly_name: "Kitchen fan",
device_class: "fan",
percentage: 50,
percentage_step: 100 / 6,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.ten_speed_fan",
state: "on",
attributes: {
friendly_name: "Living room fan",
device_class: "fan",
percentage: 70,
percentage_step: 10,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.eleven_speed_fan",
state: "on",
attributes: {
friendly_name: "Office fan",
device_class: "fan",
percentage: 45,
percentage_step: 100 / 11,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -454,55 +410,6 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds, slider style)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
config: {
type: "tile",
entity: "fan.on_off_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (6 speeds, slider by default)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (6 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (10 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading:
"Fan speed feature (11 speeds, buttons style falls back to slider)",
config: {
type: "tile",
entity: "fan.eleven_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
@@ -523,27 +430,6 @@ const CONFIGS = [
],
},
},
{
heading:
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
{
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
+29 -29
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.1.0",
"@babel/runtime": "8.0.8",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -54,17 +54,17 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@formatjs/intl-datetimeformat": "7.8.2",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.15",
"@formatjs/intl-locale": "5.3.12",
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.16",
"@formatjs/intl-relativetimeformat": "12.3.16",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/common": "1.5.4",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -78,11 +78,11 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "8.0.0",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "7.0.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@vvo/tzdb": "6.201.1",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
@@ -100,8 +100,8 @@
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"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.2",
"marked": "18.1.1",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -137,14 +137,14 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.1.0",
"@babel/core": "8.0.9",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.23.0",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
@@ -153,8 +153,8 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.3.0",
"@rspack/dev-server": "2.2.2",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
"@types/chromecast-caf-sender": "1.0.11",
@@ -173,11 +173,11 @@
"@vitest/coverage-v8": "5.0.5",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.5",
"browserslist": "4.29.4",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.51.0",
"del": "9.0.1",
"eslint": "10.14.0",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -188,8 +188,8 @@
"fancy-log": "2.0.0",
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "14.0.3",
"globals": "17.14.0",
"glob": "13.0.6",
"globals": "17.13.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -214,7 +214,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"typescript-eslint": "8.72.0",
"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]6.1",
"packageManager": "[email protected]5.0",
"volta": {
"node": "24.22.2"
"node": "24.22.1"
}
}
+4 -63
View File
@@ -1,8 +1,8 @@
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
--- a/modular/sortable.core.esm.js
+++ b/modular/sortable.core.esm.js
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
}
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
capture();
@@ -24,7 +24,7 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
}
parentEl = el; // actualization
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
targetRect = getRect(target);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
capture();
@@ -38,16 +38,7 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
parentEl = el; // actualization
changed();
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
pastFirstInvertThresh = false;
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
var sibling;
if (direction !== 0) {
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
_silent = true;
setTimeout(_unsilent, 30);
capture();
@@ -67,53 +58,3 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
}
// Undo chrome's scroll adjustment (has no effect on other browsers)
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
function _unsilent() {
_silent = false;
}
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
+// checks below keep assuming that the list runs from left to right.
+function _isRtl(el, vertical) {
+ return !vertical && css(el, 'direction') === 'rtl';
+}
+function _mirrorX(rect) {
+ return {
+ left: -rect.right,
+ right: -rect.left,
+ top: rect.top,
+ bottom: rect.bottom,
+ width: rect.width,
+ height: rect.height
+ };
+}
function _ghostIsFirst(evt, vertical, sortable) {
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ firstElRect = _mirrorX(firstElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
}
function _ghostIsLast(evt, vertical, sortable) {
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ lastElRect = _mirrorX(lastElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
}
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
+ if (rtl) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ targetRect = _mirrorX(targetRect);
+ }
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
targetLength = vertical ? targetRect.height : targetRect.width,
targetS1 = vertical ? targetRect.top : targetRect.left,
+949 -982
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
tslib: 2.9.1
tslib: 2.8.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
@@ -1,8 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 582 B

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

Before

Width:  |  Height:  |  Size: 584 B

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20261028.0"
version = "20261028.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1 -18
View File
@@ -13,9 +13,6 @@
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["pnpmDedupe"],
"constraints": {
"ghActionsLock": "v0.4.1"
},
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
"enabled": true,
@@ -62,26 +59,12 @@
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
"customType": "regex",
"managerFilePatterns": [
"/^\\.github/workflows/actions-lock\\.yaml$/",
"/^renovate\\.json$/"
],
"matchStrings": [
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
],
"depNameTemplate": "github/gh-actions-lock",
"datasourceTemplate": "github-releases"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
+12 -10
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/item/ha-list-item-button";
import "../components/list/ha-list-base";
import "../components/ha-list";
import "../components/ha-list-item";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,19 +32,21 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list-base>
<ha-list>
${this.authProviders.map(
(provider) => html`
<ha-list-item-button
<ha-list-item
hasMeta
role="button"
.auth_provider=${provider}
@click=${this._handlePick}
>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}
</ha-list-base>
</ha-list>
`;
}
@@ -78,9 +80,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list-base {
ha-list {
margin: 16px -16px 0;
--ha-row-item-padding-inline: 24px;
--mdc-list-side-padding: 24px;
}
`;
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 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);
@@ -26,10 +26,8 @@ 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: (ev?: Event) => void;
navigated: () => 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
@@ -161,7 +159,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated(ev);
this._options.navigated();
}
};
-37
View File
@@ -20,11 +20,6 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -162,35 +157,3 @@ export const findEntities = (
return results;
};
export const excludeEntities = (
entityIds: string[],
excludedEntityIds?: string[]
): string[] => {
if (!excludedEntityIds?.length) {
return entityIds;
}
const excluded = new Set(excludedEntityIds);
return entityIds.filter((entityId) => !excluded.has(entityId));
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[],
excludedEntityIds?: string[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return excludeEntities(
findEntities(Object.keys(sources.states), filterFuncs),
excludedEntityIds
);
};
+2 -2
View File
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
@@ -129,7 +129,7 @@ export const loadStyle = async (
).buildMapStyle(
mapStyle.palette,
mapStyle.options ?? {},
mapStyle.styleOptions
mapStyle.baseColors
);
if (typeof style.sprite === "string") {
+8 -4
View File
@@ -35,7 +35,7 @@ const styleUrls = () => {
export const buildMapStyle = async (
palette: MapPalette,
options: Record<string, unknown>,
styleOptions?: Record<string, unknown>
baseColors?: Record<string, string>
): Promise<StyleSpecification> => {
const [{ osm }, resolvedUrls] = await Promise.all([
import("@versatiles/style"),
@@ -50,9 +50,13 @@ export const buildMapStyle = async (
// The builder throws on an option it does not know and on a color it cannot
// parse. Those come from a card config or a theme, so one typo would
// otherwise drop the whole card to raster tiles. Step back to the style as it
// comes - colors and adjustments included, or the default would come out as
// the builder's unpainted cartography - and only then to the bare palette.
const attempts = [options, ...(styleOptions ? [styleOptions] : []), {}];
// ships - colors included, or the default would come out as the builder's
// unpainted cartography - and only then to the bare palette.
const attempts = [
options,
...(baseColors ? [{ colors: baseColors }] : []),
{},
];
let style: StyleSpecification | undefined;
for (const attempt of attempts) {
try {
+16 -74
View File
@@ -1,12 +1,11 @@
// The vector cartographies the map can draw. Each is a light and a dark
// palette; the theme mode picks the half, unless the style only has one.
// palette; the theme mode picks the half.
//
// Only the default pair is generated at build time (build-scripts/gulp/
// map-assets.js); everything else is built in the browser from the same
// builder, against the tiles and glyphs core's proxy already serves.
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
import { NEON_MAP_COLORS_DARK, NEON_MAP_OPTIONS } from "./neon-map-palette";
/** Style ids, in the order the card editor offers them */
export const MAP_STYLES = [
@@ -16,7 +15,6 @@ export const MAP_STYLES = [
"muted",
"gray",
"toner",
"neon",
] as const;
export type MapStyle = (typeof MAP_STYLES)[number];
@@ -28,19 +26,13 @@ type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
/**
* A cartography plus what is painted over it; the VersaTiles palettes have
* nothing. The color sets say which halves a style has: both, or neither, and
* the theme mode picks; only one, and that is the half the map draws.
*/
/** A cartography plus the colors painted over it; only the default has any */
const STYLE_PALETTES: Record<
MapStyle,
{
palette: VersatilesPalette;
colors?: Record<string, string>;
colorsDark?: Record<string, string>;
/** Builder options the style comes with, by option group */
options?: Record<string, Record<string, unknown>>;
}
> = {
default: {
@@ -48,11 +40,6 @@ const STYLE_PALETTES: Record<
colors: HA_MAP_COLORS,
colorsDark: HA_MAP_COLORS_DARK,
},
neon: {
palette: "colorful",
colorsDark: NEON_MAP_COLORS_DARK,
options: NEON_MAP_OPTIONS,
},
colorful: { palette: "colorful" },
natural: { palette: "natural" },
muted: { palette: "muted" },
@@ -109,12 +96,11 @@ export interface ResolvedMapStyle {
/** Fetched rather than built: the default, while nothing was added to it */
shipped?: "light" | "dark";
/**
* The style as it comes: its colors and its own adjustments, without what
* the theme or the card painted over them. What to fall back on when the
* builder rejects an adjustment, so a bad value costs the card's adjustments
* rather than the style.
* The style's own colors, without what the theme or the card painted over
* them. What to fall back on when the builder rejects an adjustment, so a
* bad value costs the adjustments rather than the cartography.
*/
styleOptions?: Record<string, unknown>;
baseColors?: Record<string, string>;
}
const isStyle = (value: unknown): value is MapStyle =>
@@ -124,31 +110,9 @@ export const isCustomMapStyle = (
style: MapStyleConfig | undefined
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
const toStyle = (style: MapStyleConfig | undefined): MapStyle => {
const named = isCustomMapStyle(style) ? style.base : style;
return isStyle(named) ? named : DEFAULT_MAP_STYLE;
};
/**
* Whether the map comes out dark: the theme mode's answer, unless the style
* only has the one half. What the map's own chrome and the theme tokens have
* to follow, rather than the theme mode.
*/
export const isDarkMapStyle = (
style: MapStyleConfig | undefined,
darkMode: boolean
): boolean => {
const { colors, colorsDark } = STYLE_PALETTES[toStyle(style)];
if (Boolean(colors) !== Boolean(colorsDark)) {
return Boolean(colorsDark);
}
return darkMode;
};
/** The half to draw; `dark` is the map's, from isDarkMapStyle */
export const paletteFor = (style: MapStyle, dark: boolean): MapPalette => {
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
const { palette } = STYLE_PALETTES[style];
return dark ? `${palette}-dark` : palette;
return darkMode ? `${palette}-dark` : palette;
};
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
@@ -234,33 +198,19 @@ export const resolveMapStyle = (
themeColors?: Record<string, string>
): ResolvedMapStyle => {
const custom = isCustomMapStyle(style) ? style : undefined;
const mapStyle = toStyle(style);
// A dark-only style picks the dark half of everything, not just the palette.
const dark = isDarkMapStyle(style, darkMode);
const named = custom ? custom.base : style;
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
const palette = paletteFor(mapStyle, dark);
const options = custom ? builderOptions(custom, dark) : {};
const palette = paletteFor(mapStyle, darkMode);
const options = custom ? builderOptions(custom, darkMode) : {};
// Before the colors below are merged in, so it answers whether the card
// asked for anything of its own.
const cardAdjusted = Object.keys(options).length > 0;
// The style's own adjustments go under the card's, group by group, so a
// card that sets one recolor value keeps the style's others.
const {
colors,
colorsDark,
options: styleOptions,
} = STYLE_PALETTES[mapStyle];
for (const [group, values] of Object.entries(styleOptions ?? {})) {
options[group] = {
...values,
...(options[group] as Record<string, unknown> | undefined),
};
}
// Bottom up: the style's colors, what the theme repaints, then what the card
// names - so a card that sets one color keeps the rest of the look.
const styleColors = dark ? colorsDark : colors;
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
if (styleColors || themeColors) {
options.colors = {
...styleColors,
@@ -273,19 +223,11 @@ export const resolveMapStyle = (
return { palette };
}
const own = styleColors || styleOptions;
const base = own
? {
styleOptions: {
...styleOptions,
...(styleColors && { colors: styleColors }),
},
}
: {};
const base = styleColors ? { baseColors: styleColors } : {};
// Anything added on top of the shipped default has to be built here.
const untouched = !themeColors && !cardAdjusted;
return mapStyle === DEFAULT_MAP_STYLE && untouched
? { palette, options, ...base, shipped: dark ? "dark" : "light" }
? { palette, options, ...base, shipped: darkMode ? "dark" : "light" }
: { palette, options, ...base };
};
-13
View File
@@ -1,13 +0,0 @@
import { isAndroidDevice } from "../../util/is_android";
import { isIosDevice } from "../../util/is_ios";
export const navigationUrl = (latitude: number, longitude: number): string => {
const point = `${latitude},${longitude}`;
if (isIosDevice()) {
return `https://maps.apple.com/?daddr=${point}`;
}
if (isAndroidDevice()) {
return `geo:${point}?q=${point}`;
}
return `https://www.openstreetmap.org/directions?to=${encodeURIComponent(point)}#map=15/${latitude}/${longitude}`;
};
-64
View File
@@ -1,64 +0,0 @@
// The "neon" style: the VersaTiles "colorful" cartography repainted as a night
// map, glowing roads on near-black ground. A dark set and no light one, which
// is what makes the style dark whatever the theme mode.
export const NEON_MAP_COLORS_DARK = {
background: "#07021a",
land: "#07021a",
water: "#13215e",
labelWater: "#00e5ff",
glacier: "#1b2a6b",
natureWood: "#0d1f2e",
natureGrass: "#101a33",
naturePark: "#12203a",
natureLeisure: "#111a30",
natureAgriculture: "#0d1528",
natureWetland: "#0e2440",
natureSand: "#1a1230",
natureRock: "#140c28",
areaResidential: "#0c0524",
areaCommercial: "#140430",
areaIndustrial: "#10062a",
areaWaste: "#160a2e",
areaBurial: "#0d0a26",
siteParking: "#0f0728",
siteSports: "#0a1c2c",
siteEducation: "#160a34",
siteHospital: "#2a0a2a",
siteDanger: "#ff1744",
sitePrison: "#1c0a28",
siteConstruction: "#3a1d5c",
building: "#160a33",
buildingBg: "#8a2be2",
roadStreet: "#00e5ff",
roadStreetBg: "#062b52",
roadSecondary: "#8f7bff",
roadSecondaryBg: "#241058",
roadTrunk: "#ff6ec7",
roadTrunkBg: "#4a0f38",
roadMotorway: "#ff2d95",
roadMotorwayBg: "#5c0033",
transitRail: "#7b2cff",
transitSubway: "#b14cff",
transitCycle: "#00ffa3",
transitFoot: "#2e2a55",
boundary: "#39ff14",
boundaryDisputed: "#1f7a0d",
label: "#ff9ad5",
labelHalo: "rgba(7,2,26,0.9)",
labelSymbol: "#ff9ad5",
labelPoi: "#00e5ff",
labelShield: "#12002a",
labelHousenumber: "rgba(0,229,255,0.35)",
};
/** Builder options the style comes with, under whatever a card adds */
export const NEON_MAP_OPTIONS = {
recolor: { saturate: 0.35, contrast: 1.15 },
text: {
transform: "uppercase",
letterSpacing: 0.12,
haloWidth: 2,
haloBlur: 0,
},
};
@@ -0,0 +1,14 @@
import type {
ListItem,
RequestSelectedDetail,
} from "@material/mwc-list/mwc-list-item";
export const shouldHandleRequestSelectedEvent = (
ev: CustomEvent<RequestSelectedDetail>
): boolean => {
if (!ev.detail.selected || ev.detail.source !== "property") {
return false;
}
(ev.currentTarget as ListItem).selected = false;
return true;
};
+2 -13
View File
@@ -16,13 +16,6 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -203,8 +196,7 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
}
@@ -223,10 +215,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
-3
View File
@@ -20,7 +20,6 @@ export const entitiesFilterQueryParamConfig = {
"floor",
"area",
"device",
"device_class",
"domain",
"entity_domain",
"label",
@@ -51,7 +50,6 @@ export const entitiesFilterQueryParams = (
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
device_class: list(filters["ha-filter-device-classes"]),
domain: list(filters["ha-filter-integrations"]),
entity_domain: list(filters["ha-filter-domains"]),
label: list(filters["ha-filter-labels"]),
@@ -116,7 +114,6 @@ export const entitiesLinkFromUrl = (
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
"ha-filter-devices": values("device"),
"ha-filter-device-classes": values("device_class"),
"ha-filter-integrations": values("domain"),
"ha-filter-domains": values("entity_domain"),
"ha-filter-labels": values("label"),
+1 -10
View File
@@ -1,4 +1,3 @@
import { SYSTEM_LOG_LEVELS, type SystemLogLevel } from "../../data/system_log";
import {
decodeQueryParams,
hasQueryParams,
@@ -18,7 +17,7 @@ export const logSearchQueryParamConfig = {
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration", "level"],
list: ["integration"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -29,19 +28,13 @@ export interface LogsState {
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
// Only apply to the Home Assistant Core errors, not the raw log
levels: SystemLogLevel[];
raw: boolean;
}
const isLevel = (level: string): level is SystemLogLevel =>
(SYSTEM_LOG_LEVELS as readonly string[]).includes(level);
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
levels: [],
raw: false,
};
@@ -60,7 +53,6 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
@@ -79,7 +71,6 @@ export const logsLinkFromUrl = (
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
raw: core && !!params.raw,
};
};
@@ -67,88 +67,3 @@ export const itemTooltipPosition: TooltipPositionCallback = (
return [x, y];
};
const ECHARTS_TOOLTIP_GAP_PX = 20;
export const WINDOW_MARGIN_PX = 8;
const fitInto = (
pos: number,
size: number,
alternatives: number[],
min: number,
max: number
) =>
size > max - min
? min
: ([pos, ...alternatives].find((p) => p >= min && p + size <= max) ??
Math.max(min, Math.min(pos, max - size)));
/**
* ECharts' default placement (or the chart-confined one), moved into the
* window when it would leave it. The other side of the cursor is tried before
* clamping, below first vertically. A tooltip taller than the window starts at
* its top. A confined tooltip stays in the chart, leaving it only as far as the
* window needs.
*/
export const windowTooltipPosition =
(
getChartRect: () => DOMRect | undefined,
confine: boolean
): TooltipPositionCallback =>
(point, _params, _dom, _rect, size) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const right = cursorX + ECHARTS_TOOLTIP_GAP_PX;
const left = cursorX - ECHARTS_TOOLTIP_GAP_PX - tipW;
const below = cursorY + ECHARTS_TOOLTIP_GAP_PX;
const above = cursorY - ECHARTS_TOOLTIP_GAP_PX - tipH;
let x = right + tipW + 2 > viewW ? left : right;
let y = below + tipH > viewH ? above : below;
if (confine) {
x = Math.max(0, Math.min(x, viewW - tipW));
y = Math.max(0, Math.min(y, viewH - tipH));
}
const rect = getChartRect();
if (!rect?.height) {
return [x, y];
}
const fit = (
pos: number,
tipSize: number,
alternatives: number[],
windowMin: number,
windowMax: number,
viewSize: number
) => {
if (!confine) {
return fitInto(pos, tipSize, alternatives, windowMin, windowMax);
}
const min = Math.max(0, windowMin);
const max = Math.min(viewSize, windowMax);
return tipSize > max - min
? fitInto(pos, tipSize, [], windowMin, windowMax)
: fitInto(pos, tipSize, alternatives, min, max);
};
const { clientWidth, clientHeight } = document.documentElement;
return [
fit(
x,
tipW,
[right, left],
WINDOW_MARGIN_PX - rect.left,
clientWidth - WINDOW_MARGIN_PX - rect.left,
viewW
),
fit(
y,
tipH,
[below, above],
WINDOW_MARGIN_PX - rect.top,
clientHeight - WINDOW_MARGIN_PX - rect.top,
viewH
),
];
};
+1 -12
View File
@@ -68,7 +68,6 @@ import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { windowTooltipPosition } from "./chart-tooltip-position";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -1253,19 +1252,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
next.appendTo = undefined;
}
if (next.position === undefined) {
// ECharts confines after the position callback, which would undo the
// window fit, so the callback confines itself
next.position = windowTooltipPosition(
() => this._chartContainer?.getBoundingClientRect(),
isMobile || next.confine === true
);
next.confine = false;
} else if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
}
if (isMobile || this._touchInput) {
next.triggerOn = "click";
+4 -10
View File
@@ -31,14 +31,8 @@ export interface NetworkNode {
color?: string;
borderColor?: string;
borderWidth?: number;
opacity?: number;
};
label?: {
position?: "left" | "right" | "top" | "bottom";
show?: boolean;
fontWeight?: "normal" | "bold";
fontSize?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -57,7 +51,6 @@ export interface NetworkLink {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
opacity?: number;
};
symbolSize?: number | number[];
symbol?: string;
@@ -293,8 +286,9 @@ export class HaNetworkGraph extends LitElement {
return node.name ?? "";
},
rich: {
// inherits the node label font, so a node can override it
primary: {},
primary: {
fontSize: 12,
},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
+42 -55
View File
@@ -22,8 +22,6 @@ export interface Node {
color?: string;
passThrough?: boolean;
entityId?: string;
// Nodes of one group take a single position when ordering their children
group?: string;
}
export interface Link {
source: string;
@@ -48,46 +46,6 @@ const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
// Links without a value take the remaining capacity of their nodes, in order.
export const fillSankeyLinkValues = (
nodes: Node[],
rawLinks: Link[]
): ProcessedLink[] => {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
};
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@property({ attribute: false }) public data: SankeyChartData = {
@@ -232,7 +190,7 @@ export class HaSankeyChart extends LitElement {
);
}
});
const links = fillSankeyLinkValues(filteredNodes, data.links);
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
@@ -346,31 +304,60 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
const countedGroups = new Set<string>();
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const positions = [...new Set(sections[i].map((n) => n.group ?? n.id))];
const node = sections[i].find((n) => n.id === parent);
if (node) {
if (node.group !== undefined) {
if (countedGroups.has(node.group)) {
break;
}
countedGroups.add(node.group);
}
sum += offset + positions.indexOf(node.group ?? node.id);
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
count++;
break;
}
offset += positions.length;
offset += section.length;
}
}
return count > 0 ? sum / count : -1;
@@ -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-base>
<ha-list>
${repeat(
columns,
(col) => col.key,
@@ -152,23 +152,23 @@ export class DialogDataTableSettings extends LitElement {
col.defaultHidden)
: col.defaultHidden);
return html`<ha-list-item-base
return html`<ha-list-item
hasMeta
class=${classMap({
hidden: !isVisible,
draggable: canMove && isVisible,
})}
>
<span slot="headline"
>${col.title || col.label || col.key}</span
>
graphic="icon"
noninteractive
>${col.title || col.label || col.key}
${
canMove && isVisible
? html`<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
slot="start"
slot="graphic"
></ha-svg-icon>`
: html`<span class="handle-spacer" slot="start"></span>`
: nothing
}
<ha-icon-button
tabindex="0"
@@ -176,7 +176,7 @@ export class DialogDataTableSettings extends LitElement {
.disabled=${!canHide}
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
slot="meta"
.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-base>`;
</ha-list-item>`;
}
)}
</ha-list-base>
</ha-list>
</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-base {
--ha-row-item-padding-inline: 12px;
ha-list-item {
--mdc-list-side-padding: 12px;
overflow: visible;
}
.hidden {
@@ -345,11 +345,6 @@ export class DialogDataTableSettings extends LitElement {
display: block;
margin: -12px;
}
.handle-spacer {
display: block;
width: 24px;
height: 24px;
}
`,
];
}
+12 -21
View File
@@ -1,5 +1,6 @@
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";
@@ -16,7 +17,6 @@ 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 "../item/ha-list-item-button";
import "../list/ha-list-base";
import "../ha-list";
import "../ha-list-item";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
@@ -166,19 +166,11 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
return html`
<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 @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list-base>
</ha-list>
`;
}
@@ -351,12 +343,11 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { range: [Date, Date]; index: number }
>
) {
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
@@ -161,8 +161,7 @@ export abstract class HaDeviceAutomationPicker<
return html`<ha-generic-picker
.label=${this.label}
.value=${value}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(
value,
this._automations,
+1 -37
View File
@@ -189,10 +189,6 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _loadFailed = false;
private _statisticIdsRequest = 0;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
@@ -205,36 +201,11 @@ export class HaStatisticPicker extends LitElement {
}
private async _getStatisticIds() {
// Changing the statistic types starts a new request, and only the latest
// one may update the picker
const request = ++this._statisticIdsRequest;
this._loadFailed = false;
let statisticIds: StatisticsMetaData[];
try {
statisticIds = await getStatisticIds(this._api, this.statisticTypes);
} catch {
if (request === this._statisticIdsRequest) {
this._loadFailed = true;
}
return;
}
if (request !== this._statisticIdsRequest) {
return;
}
this.statisticIds = statisticIds;
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _retryGetStatisticIds(ev: Event) {
ev.stopPropagation();
this._getStatisticIds();
}
private _getItems = () =>
this._getStatisticsItems(
this._states,
@@ -618,12 +589,6 @@ export class HaStatisticPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.loading=${!this._loadFailed && this.statisticIds === undefined}
.loadError=${
this._loadFailed
? this._i18n.localize("ui.components.statistic-picker.load_failed")
: undefined
}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@@ -633,7 +598,6 @@ export class HaStatisticPicker extends LitElement {
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryGetStatisticIds}
>
</ha-generic-picker>
`;
+5 -19
View File
@@ -7,7 +7,6 @@ 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";
@@ -22,10 +21,8 @@ import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import {
brandsAccessTokenContext,
connectionContext,
uiContext,
} from "../../data/context";
import type { HomeAssistantUI } from "../../types";
import { darkBrandUrl, isBrandUrl } from "../../util/brands-url";
import { isBrandUrl } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
@@ -59,13 +56,6 @@ 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 {
@@ -151,9 +141,7 @@ export class StateBadge extends LitElement {
!changedProps.has("stateColor") &&
!changedProps.has("color") &&
!changedProps.has("_connection") &&
!changedProps.has("_brandsAccessToken") &&
// Brand images have a dark variant, so the picture changes with the theme
!changedProps.has("_darkMode")
!changedProps.has("_brandsAccessToken")
) {
return;
}
@@ -229,17 +217,15 @@ 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. In dark mode, brand images (e.g. the integration icon of an
// update entity) use their dark-optimized variant.
// we can sign.
private _resolveImageUrl(url: string | undefined): string {
if (!url) {
return "";
}
const themedUrl = this._darkMode ? darkBrandUrl(url) : url;
if (this._connection) {
return this._connection.hassUrl(themedUrl);
return this._connection.hassUrl(url);
}
return isBrandUrl(themedUrl) ? "" : themedUrl;
return isBrandUrl(url) ? "" : url;
}
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)[] = [];
+93
View File
@@ -0,0 +1,93 @@
import { CheckListItemBase } from "@material/mwc-list/mwc-check-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 { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { preventDefault } from "../common/dom/prevent_default";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-checkbox";
@customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
@property({ type: Boolean, attribute: "separate-checkbox-click" })
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<ha-checkbox
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
class=${this.left ? "left" : ""}
>
</ha-checkbox>
${this.left ? text : ""} ${meta}`;
}
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);
}
ha-checkbox {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
height: 100%;
justify-content: center;
}
ha-checkbox.left {
margin-inline-start: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-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-check-list-item": HaCheckListItem;
}
}
@@ -26,7 +26,10 @@ import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<ClockCardDatePart, `separator-${string}`>;
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
@@ -40,7 +43,6 @@ const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-comma": ",",
"separator-new-line": "",
};
-4
View File
@@ -62,10 +62,6 @@ export class HaControlSelect extends LitElement {
});
}
public override focus(): void {
this._focusOption(this._tabbableIndex);
}
private _handleBlur(ev: FocusEvent) {
// Only reset if focus is leaving the entire component
if (!this.contains(ev.relatedTarget as Node)) {
+9 -37
View File
@@ -5,12 +5,10 @@ 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 {
@@ -45,8 +43,6 @@ export class HaControlSlider extends LitElement {
@property({ attribute: false }) public locale?: FrontendLocaleData;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@property({ type: Boolean, reflect: true })
public disabled = false;
@@ -143,28 +139,12 @@ export class HaControlSlider extends LitElement {
this.setupListeners();
}
protected updated(changedProps: PropertyValues) {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
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("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("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
@@ -332,13 +312,6 @@ 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);
@@ -376,8 +349,7 @@ export class HaControlSlider extends LitElement {
}
protected render(): TemplateResult {
const valuenow =
this.value != null ? this._displayedValue(this.value) : undefined;
const valuenow = this._displayedValue(this.value ?? 0);
return html`
<div
class="container${classMap({
@@ -393,8 +365,8 @@ export class HaControlSlider extends LitElement {
role="slider"
tabindex="0"
aria-label=${ifDefined(this.label)}
aria-valuenow=${ifDefined(valuenow?.toString())}
aria-valuetext=${ifDefined(this._ariaValueText() || undefined)}
aria-valuenow=${valuenow.toString()}
aria-valuetext=${this._formatValue(valuenow)}
aria-valuemin=${ifDefined(
this.min != null ? this.min.toString() : undefined
)}
@@ -423,7 +395,7 @@ export class HaControlSlider extends LitElement {
class=${classMap({
"slider-track-bar": true,
[this.mode ?? "start"]: true,
"show-handle": this.showHandle && this.value != null,
"show-handle": this.showHandle,
})}
></div>
`
+4 -65
View File
@@ -5,13 +5,10 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import { computeUnitDeviceClasses } from "../data/sensor/unit_of_measurement";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
@@ -30,13 +27,6 @@ export const getDeviceClassOptions = (
return { id: deviceClass, primary, sorting_label: primary };
});
// The reason line of a device class that does not support the unit names the
// unit, so searching it would match every unsupported device class
const UNIT_FILTER_SEARCH_KEYS: FuseWeightedKey[] = [
{ name: "primary", weight: 10 },
{ name: "id", weight: 3 },
];
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@@ -53,9 +43,6 @@ export class HaDeviceClassPicker extends LitElement {
@property({ type: Boolean }) public required = false;
/** Unit of measurement: device classes that do not support it are listed last */
@property() public unit?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -83,60 +70,18 @@ export class HaDeviceClassPicker extends LitElement {
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined,
unit: string | undefined,
language: string | undefined
): PickerComboBoxItem[] => {
if (!domain || !localize) {
return [];
}
const options = getDeviceClassOptions(domain, localize);
const fitting = computeUnitDeviceClasses(domain, unit);
if (!fitting) {
return options;
}
// Device classes of the unit first, then those that accept any unit
const rank = (deviceClass: string) =>
fitting.matching.includes(deviceClass)
? 0
: fitting.unrestricted.includes(deviceClass)
? 1
: 2;
const notSupported = localize(
"ui.components.device-class-picker.unit_not_supported",
{ unit }
);
return options
.map((option) =>
rank(option.id) === 2
? { ...option, secondary: notSupported }
: option
)
.sort(
(a, b) =>
rank(a.id) - rank(b.id) ||
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
}
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
);
// The options are sorted here when the unit filters them
private get _filtered(): boolean {
return computeUnitDeviceClasses(this.domain, this.unit) !== undefined;
}
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(
this.domain,
this._i18n?.localize,
this.unit,
this._i18n?.language
);
const options = this._options(this.domain, this._i18n?.localize);
return this.multiple
? this._availableOptions(options, this._value)
: options;
@@ -159,8 +104,6 @@ export class HaDeviceClassPicker extends LitElement {
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
const filtered = this._filtered;
const searchKeys = filtered ? UNIT_FILTER_SEARCH_KEYS : undefined;
if (this.multiple) {
const value = this._value;
@@ -202,8 +145,6 @@ export class HaDeviceClassPicker extends LitElement {
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
@@ -223,8 +164,6 @@ export class HaDeviceClassPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
+32 -40
View File
@@ -1,4 +1,5 @@
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";
@@ -11,11 +12,6 @@ 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";
@@ -23,12 +19,11 @@ 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 "./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 "./ha-list";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@@ -93,30 +88,23 @@ export class HaFilterBlueprints extends LitElement {
${
this._blueprints
? html`
<ha-list-selectable
<ha-list
@selected=${this._blueprintsSelected}
multi
aria-label=${this._localize(
"ui.panel.config.blueprint.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
: html`<ha-check-list-item
.value=${id}
.selected=${this.value?.includes(id) ?? false}
.selected=${(this.value || []).includes(id)}
>
<span slot="headline"
>${blueprint.metadata.name || id}</span
>
</ha-list-item-option>`
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list-selectable>
</ha-list>
`
: nothing
}
@@ -138,20 +126,28 @@ export class HaFilterBlueprints extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
private async _blueprintsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const blueprintId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!blueprintId) {
const blueprints = this._blueprints!;
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, blueprintId)
: arrayWithout(this.value, blueprintId);
const value: string[] = [];
for (const index of ev.detail.index) {
const blueprintId = Object.keys(blueprints)[index];
value.push(blueprintId);
}
this.value = value;
}
private async _findRelated() {
@@ -206,14 +202,10 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
ha-list {
flex: 1;
min-height: 0;
}
+7 -11
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,14 +226,10 @@ export class HaFilterCategories extends LitElement {
</ha-list-item-option>`
)}
</ha-list-selectable>
<ha-button
appearance="filled"
size="s"
@click=${this._addCategory}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.panel.config.category.editor.add")}
</ha-button>
</ha-list-item>
</div>
`
: nothing
@@ -390,12 +386,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);
}
@@ -1,78 +0,0 @@
import type { ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeDomain } from "../common/entity/compute_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import { stringCompare } from "../common/string/compare";
import { internationalizationContext, statesContext } from "../data/context";
import { computeDeviceClassName } from "../data/entity/device_class";
import { HaFilterStates } from "./ha-filter-states";
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends HaFilterStates {
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates?: ContextType<typeof statesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string
) => {
const classes = new Map<string, string>();
for (const [entityId, stateObj] of Object.entries(states)) {
const deviceClass = stateObj.attributes.device_class;
if (typeof deviceClass !== "string" || classes.has(deviceClass)) {
continue;
}
classes.set(
deviceClass,
computeDeviceClassName(localize, computeDomain(entityId), deviceClass)
);
}
return [...classes]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => stringCompare(a.label, b.label, language));
}
);
protected firstUpdated() {
this._i18n?.loadBackendTranslation("entity_component");
}
protected willUpdate() {
if (
this._entityStates &&
this._localize &&
this._i18n &&
(this.expanded || !this.states)
) {
this.states = this._deviceClasses(
this._entityStates,
this._localize,
this._i18n.locale.language
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+1 -9
View File
@@ -85,15 +85,6 @@ 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
@@ -278,6 +269,7 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static styles = [
+38 -44
View File
@@ -1,4 +1,5 @@
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";
@@ -12,23 +13,17 @@ 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 "./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 "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -87,38 +82,35 @@ export class HaFilterDomains extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
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._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
html`<ha-check-list-item
.value=${domain}
.selected=${this.value?.includes(domain) ?? false}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="start"
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
<span slot="headline"
>${domainToName(this._localize, domain)}</span
>
</ha-list-item-option>`
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list-selectable>
</ha-list>
</div>`
: nothing
}
@@ -130,7 +122,8 @@ export class HaFilterDomains extends LitElement {
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
filter: string | undefined,
_value
) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
@@ -157,20 +150,22 @@ export class HaFilterDomains extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const domain = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!domain) {
return;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
);
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, domain)
: arrayWithout(this.value, domain);
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];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
@@ -194,14 +189,10 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
ha-list {
flex: 1;
min-height: 0;
}
@@ -213,6 +204,9 @@ 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;
+61 -57
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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
@@ -12,7 +12,6 @@ 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";
@@ -26,16 +25,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 extends HaListVirtualizedItem {
interface EntityItem {
stateObj: HassEntity;
name: string;
}
@@ -75,14 +74,15 @@ 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,15 +91,6 @@ 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
@@ -130,14 +121,23 @@ export class HaFilterEntities extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<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>
<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>
</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-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
: html`<ha-check-list-item
tabindex="0"
.value=${item.stateObj.entity_id}
.selected=${this.value?.includes(item.stateObj.entity_id) ?? false}
graphic="icon"
>
<ha-state-icon
slot="start"
slot="graphic"
.stateObj=${item.stateObj}
></ha-state-icon>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
${item.name}
</ha-check-list-item>`;
private _handleItemToggled(ev: CustomEvent<number>) {
const entityId = this._currentEntities()[ev.detail]?.id;
if (!entityId) {
return;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
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 _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
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);
}
private _expandedChanged(ev) {
@@ -199,13 +199,12 @@ export class HaFilterEntities extends LitElement {
formatters: ContextType<typeof formattersContext>,
type: this["type"],
filter: string,
language: string | undefined
language: string | undefined,
_value
): EntityItem[] =>
Object.values(states)
.filter((stateObj) => !type || computeStateDomain(stateObj) !== type)
.map((stateObj) => ({
id: stateObj.entity_id,
interactive: true,
stateObj,
name: formatters.formatEntityName(stateObj),
}))
@@ -268,9 +267,10 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable-virtualized {
ha-list {
flex: 1;
min-height: 0;
}
@@ -298,6 +298,10 @@ 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;
+41 -98
View File
@@ -1,11 +1,11 @@
import type { SelectedDetail } from "@material/mwc-list";
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -13,37 +13,20 @@ 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-badge";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -52,12 +35,6 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -115,42 +92,37 @@ export class HaFilterIntegrations extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
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-list-item-option
appearance="checkbox"
selection-position="end"
html`<ha-check-list-item
.value=${integration.domain}
.selected=${
this.value?.includes(integration.domain) ??
false
}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="start"
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
<span slot="headline">${integration.name}</span>
${this._renderCounts(integration.domain)}
</ha-list-item-option>`
${integration.name}
</ha-check-list-item>`
)}
</ha-list-selectable>`
</ha-list>`
: nothing
}
</div>`
@@ -159,32 +131,6 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string): TemplateResult | typeof nothing {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span slot="supporting-text" class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -198,6 +144,7 @@ export class HaFilterIntegrations extends LitElement {
localize: LocalizeFunc,
manifest: IntegrationManifest[],
filter: string | undefined,
_value: string[] | undefined,
language: string
) =>
manifest
@@ -218,20 +165,26 @@ export class HaFilterIntegrations extends LitElement {
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const domain = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!domain) {
private _itemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!this._manifests) {
return;
}
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, domain)
: arrayWithout(this.value, domain);
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];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
@@ -255,14 +208,10 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
ha-list {
flex: 1;
min-height: 0;
}
@@ -274,6 +223,9 @@ 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;
@@ -294,15 +246,6 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
margin-top: var(--ha-space-1);
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+49 -51
View File
@@ -1,4 +1,5 @@
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";
@@ -12,29 +13,22 @@ 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 {
@@ -63,10 +57,12 @@ 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
language: string | undefined,
_value
) =>
labels
.filter(
@@ -109,28 +105,26 @@ export class HaFilterLabels extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
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._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
html`<ha-check-list-item
.value=${label.label_id}
.selected=${this.value?.includes(label.label_id) ?? false}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
slot="headline"
.color=${label.color}
.description=${label.description}
>
@@ -144,17 +138,13 @@ export class HaFilterLabels extends LitElement {
}
${label.name}
</ha-label>
</ha-list-item-option>`
</ha-check-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>
</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>
</div>`
: nothing
}
@@ -174,23 +164,35 @@ export class HaFilterLabels extends LitElement {
this._filter = value.toLowerCase();
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const labelId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!labelId) {
return;
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);
}
}
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, labelId)
: arrayWithout(this.value, labelId);
const value = [...preservedLabels, ...newlySelectedLabels];
this.value = value.length ? value : [];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
value: value.length ? value : undefined,
items: undefined,
});
}
@@ -206,14 +208,10 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
ha-list {
flex: 1;
min-height: 0;
}
+31 -39
View File
@@ -1,26 +1,20 @@
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 "./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 "./ha-list";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@@ -46,6 +40,7 @@ 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
@@ -69,32 +64,30 @@ export class HaFilterStates extends LitElement {
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list-selectable
<ha-list
@selected=${this._statesSelected}
multi
aria-label=${ifDefined(this.label)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
class="ha-scrollbar"
>
${this.states.map(
(item) =>
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
html`<ha-check-list-item
.value=${item.value}
.selected=${this.value?.includes(item.value) ?? false}
.graphic=${hasIcon ? "icon" : null}
>
${
item.icon
? html`<ha-icon
slot="start"
slot="graphic"
.icon=${item.icon}
></ha-icon>`
: nothing
}
<span slot="headline">${item.label}</span>
</ha-list-item-option>`
${item.label}
</ha-check-list-item>`
)}
</ha-list-selectable>
</ha-list>
</div>
`
: nothing
@@ -106,23 +99,26 @@ export class HaFilterStates extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const state = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!state) {
private async _statesSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, state)
: arrayWithout(this.value, state);
const value: string[] = [];
for (const index of ev.detail.index) {
const val = this.states![index].value;
value.push(val);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value: this.value,
value,
items: undefined,
});
}
@@ -138,14 +134,10 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
ha-list {
flex: 1;
min-height: 0;
}
+34 -41
View File
@@ -1,3 +1,4 @@
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";
@@ -10,19 +11,14 @@ 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 "./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 "./ha-label";
import "./ha-list";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
@@ -68,35 +64,30 @@ export class HaFilterVoiceAssistants extends LitElement {
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list-selectable
<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
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-list-item-option
appearance="checkbox"
selection-position="end"
html`<ha-check-list-item
.value=${voiceAssistantId}
.selected=${this.value?.includes(voiceAssistantId) ?? false}
.selected=${(this.value || []).includes(voiceAssistantId)}
hasMeta
graphic="icon"
>
<voice-assistant-brand-icon
slot="start"
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
>
</voice-assistant-brand-icon>
<span slot="headline"
>${voiceAssistants[voiceAssistantId].name}</span
>
</ha-list-item-option>`
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list-selectable>
</ha-list>
</div>`
: nothing
}
@@ -112,20 +103,23 @@ export class HaFilterVoiceAssistants extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
private async _assistantsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const voiceAssistantId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!voiceAssistantId) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, voiceAssistantId)
: arrayWithout(this.value, voiceAssistantId);
const newvalue: string[] = [];
for (const index of ev.detail.index) {
newvalue.push(this._voiceAssistantOptions![index]);
}
this.value = newvalue;
fireEvent(this, "data-table-filter-changed", {
value: this.value,
@@ -144,14 +138,10 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
ha-list {
flex: 1;
min-height: 0;
}
@@ -163,6 +153,9 @@ 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;
+43 -161
View File
@@ -2,7 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { mdiPlaylistPlus, mdiRefresh } from "@mdi/js";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
html,
@@ -17,15 +17,13 @@ import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import { configContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-alert";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
@@ -35,7 +33,6 @@ import type {
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import "./ha-svg-icon";
import "./skeleton/ha-skeleton";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@@ -117,15 +114,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
/** Shows a skeleton in place of the field while its items load. */
@property({ type: Boolean, reflect: true }) public loading = false;
/**
* Shows this error with a retry button in place of the field when its items
* failed to load. The retry button fires `picker-retry`.
*/
@property({ attribute: false }) public loadError?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -136,14 +124,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// Set once loading starts, then hidden with CSS once it ends, so the
// skeleton fades out over the field
@state() private _skeletonShown = false;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -175,14 +155,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("loading") && this.loading) {
this._skeletonShown = true;
}
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState") ||
changedProperties.has("loading") ||
changedProperties.has("loadError")
changedProperties.has("noUnknownState")
) {
this._setUnknownValue();
}
@@ -208,73 +183,45 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: nothing
}
<div id="picker">
<div class="field" ?inert=${this.loading || !!this.loadError}>
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._skeletonShown
? html`<ha-skeleton
class="loading-skeleton"
role=${ifDefined(this.loading ? "progressbar" : undefined)}
aria-label=${ifDefined(
this.loading ? this._loadingLabel() : undefined
)}
></ha-skeleton>`
: nothing
}
${
this.loadError
? html`<div class="load-error">
<ha-alert alert-type="error">
${this.loadError}
<ha-icon-button
slot="action"
.path=${mdiRefresh}
.label=${this._i18n?.localize("ui.common.retry")}
@click=${this._retry}
></ha-icon-button>
</ha-alert>
</div>`
: nothing
}
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._pickerWrapperOpen || this._opened
@@ -351,25 +298,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _loadingLabel() {
const label = this.ariaLabel || this.label || this.placeholder;
return label
? this._i18n?.localize("ui.components.generic-picker.loading", {
label,
})
: this._i18n?.localize("ui.common.loading");
}
private _retry(ev: Event) {
ev.stopPropagation();
fireEvent(this, "picker-retry");
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.loading ||
this.loadError ||
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
@@ -482,8 +413,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
) {
ev?.stopPropagation();
if (this.disabled || this.loading || this.loadError) {
if (this.disabled) {
return;
}
this._selectedValue = options?.selectedValue;
@@ -529,53 +459,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block;
max-width: 100%;
}
/* The error shares the field's cell, so a wrapping error grows the
picker instead of covering what follows */
#picker {
position: relative;
display: grid;
}
#picker > .field,
#picker > .load-error {
grid-area: 1 / 1;
min-width: 0;
}
/* The field keeps its place under the skeleton, so nothing moves and
the two cross fade once loading ends */
.field {
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
:host([loading]) .field,
.field[inert] {
opacity: 0;
}
.loading-skeleton {
position: absolute;
inset: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-sm);
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
@starting-style {
.loading-skeleton {
opacity: 0;
}
}
/* Hidden once faded out, and at once when no fade runs */
:host(:not([loading])) .loading-skeleton {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease-out,
visibility 0s linear var(--ha-animation-duration-fast, 150ms);
}
.load-error {
display: flex;
align-items: center;
}
.load-error ha-alert {
flex: 1;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
@@ -657,6 +540,5 @@ declare global {
interface HASSDomEvents {
"picker-closed": undefined;
"picker-opened": undefined;
"picker-retry": undefined;
}
}
+1 -2
View File
@@ -178,8 +178,7 @@ export class HaNavigationPicker extends LitElement {
allow-custom-value
.placeholder=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.loading=${this._loading}
.disabled=${this._loading || this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
+21 -78
View File
@@ -15,7 +15,6 @@ 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,
@@ -24,7 +23,6 @@ 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 {
@@ -166,10 +164,6 @@ 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;
@@ -230,18 +224,12 @@ 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;
@@ -263,18 +251,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
public willUpdate(changedProps: PropertyValues) {
public willUpdate() {
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) {
@@ -284,13 +267,6 @@ 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
@@ -327,8 +303,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
disconnectedCallback() {
super.disconnectedCallback();
this._removeKeyboardShortcuts?.();
this._updateStatus.cancel();
this._status = undefined;
}
public refreshItems() {
@@ -388,28 +362,9 @@ 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
@@ -507,25 +462,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
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 _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
@@ -567,7 +505,15 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
items.push(...additionalItems);
if (this.allowCustomValue && this._search) {
items.push(this._getCustomValueItem(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,
});
}
if (this.mode === "dialog") {
@@ -728,7 +674,15 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
if (this.allowCustomValue && searchString) {
filteredItems.push(this._getCustomValueItem(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,
});
}
this._items = filteredItems;
@@ -1074,17 +1028,6 @@ 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
@@ -0,0 +1,52 @@
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,9 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { DeviceClassSelector } from "../../data/selector";
import { computeDeviceClassForUnit } from "../../data/sensor/unit_of_measurement";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
@@ -20,17 +17,12 @@ export class HaDeviceClassSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
filter_unit_of_measurement?: string;
};
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.unit=${this.context?.filter_unit_of_measurement}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -39,39 +31,6 @@ export class HaDeviceClassSelector extends LitElement {
`;
}
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
// A unit does not imply a device class, so an optional field stays empty
if (
!this.required ||
!changedProps.has("context") ||
this.value ||
this.selector.device_class?.multiple
) {
return;
}
// Preselect only when the unit changes, so a form that opens with a unit
// and a device class the user cleared stay without a device class
const oldContext = changedProps.get("context");
const unit = this.context?.filter_unit_of_measurement;
if (
oldContext === undefined ||
oldContext.filter_unit_of_measurement === unit
) {
return;
}
// Preselect the only device class of the unit
const deviceClass = computeDeviceClassForUnit(
this.selector.device_class?.domain,
unit
);
if (deviceClass) {
fireEvent(this, "value-changed", { value: deviceClass });
}
}
static styles = css`
ha-device-class-picker {
width: 100%;
@@ -162,7 +162,7 @@ export class HaObjectSelector extends LitElement {
description = this._formatField(item, descriptionField);
}
const reorderable = this._reorderable;
const reorderable = this.selector.object!.multiple || false;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-list-item-base class="item">
@@ -212,7 +212,6 @@ export class HaObjectSelector extends LitElement {
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
.disabled=${!this._reorderable}
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
@@ -292,18 +291,11 @@ export class HaObjectSelector extends LitElement {
}));
});
private get _reorderable(): boolean {
return (
(this.selector.object?.multiple ?? false) &&
this.selector.object?.reorder !== false
);
}
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this._reorderable) {
if (!this.selector.object!.multiple) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff. Show more