mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 21:45:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32bc0696e | ||
|
|
6327304f4d | ||
|
|
11c79e09e9 | ||
|
|
5060b37381 | ||
|
|
63e3a04f15 | ||
|
|
818de1da8e | ||
|
|
70897feab1 | ||
|
|
f827c09e72 | ||
|
|
a4bb97b71c | ||
|
|
509561aabe | ||
|
|
0446eb5a62 | ||
|
|
e815c44a42 | ||
|
|
4551a87a7a | ||
|
|
23e376c7b4 | ||
|
|
a67b691a45 | ||
|
|
fc0f86e46d | ||
|
|
ef6438a77d | ||
|
|
7b4981e850 | ||
|
|
a46e90de67 | ||
|
|
3dfadb7fdf | ||
|
|
6f3ef16d20 | ||
|
|
17d08d26f6 | ||
|
|
aa06e8ce25 | ||
|
|
122877cdf5 | ||
|
|
a103eac243 | ||
|
|
a9a8a9aad9 | ||
|
|
35a054c1ba | ||
|
|
960e99ed66 | ||
|
|
232375befb | ||
|
|
91ee29a2e6 | ||
|
|
a6dc5d0008 | ||
|
|
7a66de362e | ||
|
|
36500e5b19 | ||
|
|
df88136147 | ||
|
|
2f1d738c2c | ||
|
|
bfeb26b47d | ||
|
|
8de5bfee89 | ||
|
|
1efc7f8a95 | ||
|
|
9e0d38fba8 | ||
|
|
2932f8f528 | ||
|
|
6a0902d18c | ||
|
|
4424f98eb6 | ||
|
|
ea13594fb0 | ||
|
|
ce726a8a3f | ||
|
|
d67479da6d | ||
|
|
275e30879c | ||
|
|
7b53005865 | ||
|
|
94950fcacd | ||
|
|
4214fe46c6 | ||
|
|
d6325eef7d | ||
|
|
12e722f122 | ||
|
|
83eb3ca13a | ||
|
|
0532b1f5f6 | ||
|
|
970afb588d | ||
|
|
23137ce6d2 | ||
|
|
6b022d63e8 | ||
|
|
d3c6e132b7 | ||
|
|
c08b261d4b | ||
|
|
0721b22036 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -30,17 +30,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: $/.github/actions/setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: $/.github/actions/build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
@@ -62,17 +62,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: $/.github/actions/setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: $/.github/actions/build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -31,17 +31,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: $/.github/actions/setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: $/.github/actions/build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
@@ -62,17 +62,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: $/.github/actions/setup
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: $/.github/actions/build
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -244,6 +244,9 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
@@ -8,13 +8,13 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/manifest.json
|
||||
Cache-Control: public, max-age: 3600, s-maxage=3600
|
||||
Cache-Control: public, max-age=3600, s-maxage=3600
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
@@ -1,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;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/api/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/embed.js
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "../../../src/common/decorators/consume";
|
||||
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { embedHassContext } from "./context";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { readScriptObject } from "./script-data";
|
||||
|
||||
const pageProvider = (): HTMLElement =>
|
||||
document.querySelector("ha-embed-provider") ??
|
||||
document.body.appendChild(document.createElement("ha-embed-provider"));
|
||||
|
||||
// Looks past shadow roots, so an element inside another component still
|
||||
// finds the provider around that component.
|
||||
const insideProvider = (element: Element): boolean => {
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current) {
|
||||
if (current.closest("ha-embed-provider")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const root = current.getRootNode();
|
||||
current = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render dashboard content. The element
|
||||
* uses the provider with the ID in `provider`, else the closest provider
|
||||
* around it, else the first provider on the page. If the page has no
|
||||
* provider, the element adds an empty one.
|
||||
*/
|
||||
export abstract class EmbedElement<
|
||||
Config extends ScriptObject,
|
||||
> extends LitElement {
|
||||
/**
|
||||
* The configuration of the content, the same as in a dashboard. Also read
|
||||
* from a JSON or YAML script child, see `readScriptObject`.
|
||||
*/
|
||||
@property({ type: Object }) public config?: Config;
|
||||
|
||||
/** The ID of the `ha-embed-provider` to use. */
|
||||
@property() public provider?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: embedHassContext, subscribe: true })
|
||||
protected _hass?: HomeAssistant;
|
||||
|
||||
// A provider that is not an ancestor, which gets the context requests of the
|
||||
// element and its content. Context requests only reach ancestors by
|
||||
// themselves.
|
||||
private _linkedProvider?: HTMLElement;
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener("context-request", (ev) => {
|
||||
if (!this._linkedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
this._linkedProvider.dispatchEvent(
|
||||
new ContextEvent(
|
||||
ev.context,
|
||||
ev.contextTarget,
|
||||
ev.callback,
|
||||
ev.subscribe
|
||||
)
|
||||
);
|
||||
});
|
||||
// Cards fire their tap, hold and double tap actions for the app to handle
|
||||
this.addEventListener("hass-action", (ev) => {
|
||||
if (this._hass) {
|
||||
// The element that fired the action, so that fire-dom-event reaches
|
||||
// listeners inside custom cards, as in action-mixin
|
||||
const [origin] = ev.composedPath();
|
||||
const node = origin instanceof HTMLElement ? origin : this;
|
||||
|
||||
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
|
||||
}
|
||||
});
|
||||
// The page's address belongs to the page, so only a hash on it, such as
|
||||
// an anchor, navigates there. The browser does that itself, as the app's
|
||||
// navigate() doesn't scroll to the anchor.
|
||||
this.addEventListener("hass-navigate", (ev) => {
|
||||
const { path, replace } = ev.detail;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
if (!path.startsWith("#")) {
|
||||
this.navigateTo(path, replace);
|
||||
} else if (replace) {
|
||||
window.location.replace(path);
|
||||
} else {
|
||||
window.location.hash = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
// Before super.connectedCallback(), which sends the context requests.
|
||||
this._linkedProvider = this.provider
|
||||
? (document.getElementById(this.provider) ?? undefined)
|
||||
: insideProvider(this)
|
||||
? undefined
|
||||
: pageProvider();
|
||||
super.connectedCallback();
|
||||
|
||||
// Only once, so that moving the element keeps a config set since
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const data = readScriptObject(this);
|
||||
|
||||
if (data !== undefined) {
|
||||
this.setScriptData(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A navigate action of the content, other than to a hash on the page. It
|
||||
* leaves the page where it is, like actions that open a dialog. `replace`
|
||||
* is the action's `navigation_replace`.
|
||||
*/
|
||||
protected navigateTo(_path: string, _replace?: boolean) {
|
||||
// Nothing to navigate to, outside a dashboard
|
||||
}
|
||||
|
||||
/** Whether the data of a script child is a configuration of the content. */
|
||||
protected abstract isConfig(data: ScriptObject): data is Config;
|
||||
|
||||
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (this.isConfig(data)) {
|
||||
this.config = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** The content, or `undefined` while there is nothing to render. */
|
||||
protected abstract renderContent(
|
||||
hass: HomeAssistant
|
||||
): TemplateResult | undefined;
|
||||
|
||||
// The content in the theme of the provider
|
||||
protected render() {
|
||||
const hass = this._hass;
|
||||
const content = hass && this.renderContent(hass);
|
||||
|
||||
if (!hass || !content) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div
|
||||
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
|
||||
dir=${computeRTLDirection(hass)}
|
||||
>
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import "./workers";
|
||||
// they are defined, and cards need a provider to request their context from.
|
||||
import "./ha-embed-provider";
|
||||
import "./ha-embed-card";
|
||||
import "./ha-embed-section";
|
||||
import "./ha-embed-view";
|
||||
import "./ha-embed-dashboard";
|
||||
// For custom cards, which can't load them themselves. The built-in cards only
|
||||
// load ha-list-item-button with the map card. The full app loads ha-select
|
||||
// with the more info dialog.
|
||||
|
||||
+22
-141
@@ -1,139 +1,44 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { consume } from "../../../src/common/decorators/consume";
|
||||
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
|
||||
import "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { embedHassContext } from "./context";
|
||||
import { readScriptData } from "./script-data";
|
||||
import { embedThemeStyles } from "./theme-styles";
|
||||
|
||||
const pageProvider = (): HTMLElement =>
|
||||
document.querySelector("ha-embed-provider") ??
|
||||
document.body.appendChild(document.createElement("ha-embed-provider"));
|
||||
|
||||
// Looks past shadow roots, so a card inside another component still finds
|
||||
// the provider around that component.
|
||||
const insideProvider = (element: Element): boolean => {
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current) {
|
||||
if (current.closest("ha-embed-provider")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const root = current.getRootNode();
|
||||
current = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a Lovelace card. The card uses the provider with the ID in
|
||||
* `provider`, else the closest provider around it, else the first provider on
|
||||
* the page. If the page has no provider, the card adds an empty one.
|
||||
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
|
||||
*
|
||||
* The card takes the columns and rows it would take in a section, so cards
|
||||
* in an element with `display: grid` and 12 columns lay out like a section.
|
||||
* It also sets `--column-size` and `--row-size` on itself.
|
||||
*/
|
||||
@customElement("ha-embed-card")
|
||||
export class HaEmbedCard extends LitElement {
|
||||
/**
|
||||
* The card configuration. Also read from a JSON or YAML script child, see
|
||||
* `readScriptData`.
|
||||
*/
|
||||
@property({ type: Object }) public config?: LovelaceCardConfig;
|
||||
|
||||
/** The ID of the `ha-embed-provider` to use. */
|
||||
@property() public provider?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: embedHassContext, subscribe: true })
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
// A provider that is not an ancestor, which gets the context requests of the
|
||||
// card and its content. Context requests only reach ancestors by themselves.
|
||||
private _linkedProvider?: HTMLElement;
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
private _card = createRef<HuiCard>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener("context-request", (ev) => {
|
||||
if (!this._linkedProvider) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._linkedProvider.dispatchEvent(
|
||||
new ContextEvent(
|
||||
ev.context,
|
||||
ev.contextTarget,
|
||||
ev.callback,
|
||||
ev.subscribe
|
||||
)
|
||||
);
|
||||
});
|
||||
// Cards fire their tap, hold and double tap actions for the app to handle
|
||||
this.addEventListener("hass-action", (ev) => {
|
||||
if (this._hass) {
|
||||
// The element that fired the action, so that fire-dom-event reaches
|
||||
// listeners inside custom cards, as in action-mixin
|
||||
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
|
||||
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
|
||||
}
|
||||
});
|
||||
// A card names its type
|
||||
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
|
||||
return "type" in data && typeof data.type === "string";
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
// Before super.connectedCallback(), which sends the context requests.
|
||||
this._linkedProvider = this.provider
|
||||
? (document.getElementById(this.provider) ?? undefined)
|
||||
: insideProvider(this)
|
||||
? undefined
|
||||
: pageProvider();
|
||||
super.connectedCallback();
|
||||
|
||||
// Only once, so that moving the card keeps a config set since
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const config = readScriptData(this) as LovelaceCardConfig | undefined;
|
||||
if (config) {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._hass || !this.config) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<div
|
||||
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
|
||||
dir=${computeRTLDirection(this._hass)}
|
||||
>
|
||||
<hui-card
|
||||
${ref(this._card)}
|
||||
.hass=${this._hass}
|
||||
.config=${this.config}
|
||||
.layout=${"grid"}
|
||||
@card-updated=${this._updateSize}
|
||||
@card-visibility-changed=${this._updateSize}
|
||||
></hui-card>
|
||||
</div>`;
|
||||
return html`<hui-card
|
||||
${ref(this._card)}
|
||||
.hass=${hass}
|
||||
.config=${this.config}
|
||||
.layout=${"grid"}
|
||||
@card-updated=${this._updateSize}
|
||||
@card-visibility-changed=${this._updateSize}
|
||||
></hui-card>`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
@@ -163,7 +68,7 @@ export class HaEmbedCard extends LitElement {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
embedThemeStyles,
|
||||
embedElementStyles,
|
||||
css`
|
||||
/* The sizes of a card in a section, see hui-grid-section */
|
||||
:host {
|
||||
@@ -176,9 +81,6 @@ export class HaEmbedCard extends LitElement {
|
||||
:host([full-width]) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
:host([fit-rows]) .theme {
|
||||
height: calc(
|
||||
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
|
||||
@@ -186,27 +88,6 @@ export class HaEmbedCard extends LitElement {
|
||||
)
|
||||
);
|
||||
}
|
||||
/* The text styles the app sets on html and body, so that the card does
|
||||
not inherit the text styles of the page that embeds it. */
|
||||
.theme {
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-body);
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
text-align: start;
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
}
|
||||
/* What setDirectionStyles sets on the app for a right-to-left language,
|
||||
here only for the card */
|
||||
.theme[dir="rtl"] {
|
||||
--direction: rtl;
|
||||
--float-start: right;
|
||||
--float-end: left;
|
||||
--scale-direction: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import "../../../src/panels/lovelace/hui-root";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
|
||||
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
|
||||
* view in `view`, so switching tabs and following links to its views don't
|
||||
* change the page's URL. The dashboard scrolls inside the element, under its
|
||||
* toolbar.
|
||||
*/
|
||||
@customElement("ha-embed-dashboard")
|
||||
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
|
||||
private _scroller = createRef<HTMLDivElement>();
|
||||
|
||||
// The route of hui-root, here without a page URL around it. A new
|
||||
// configuration gives a new route, as hui-root only looks up the view of
|
||||
// the path again when its route changes, and the views may have moved.
|
||||
private _route = memoizeOne(
|
||||
(view: string | undefined, _config: LovelaceConfig) => ({
|
||||
prefix: "",
|
||||
path: `/${view ?? ""}`,
|
||||
})
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
|
||||
return isDashboardConfig(data);
|
||||
}
|
||||
|
||||
// The view shown, and the views before it, which the back button of a
|
||||
// subview without a `back_path` goes back through, as the browser's back
|
||||
// would in the app
|
||||
private _shownView?: string;
|
||||
|
||||
private _history: (string | undefined)[] = [];
|
||||
|
||||
// Going back, or a navigate action with `navigation_replace`, which
|
||||
// replaces the view shown rather than adding to the views before it
|
||||
private _skipHistory = false;
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this.view === this._shownView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._shownView !== undefined) {
|
||||
if (!this._skipHistory) {
|
||||
this._history.push(this._shownView);
|
||||
}
|
||||
|
||||
this._scroller.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
this._skipHistory = false;
|
||||
this._shownView = this.view;
|
||||
}
|
||||
|
||||
protected navigateTo(path: string, replace?: boolean) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view === undefined || this.isSameView(view, this.view)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._skipHistory = Boolean(replace);
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
// The embedded attribute is in the template, so that hui-root has it when
|
||||
// it connects and skips the listeners on the page
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this._lovelace?.config.views.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<div class="scroller" ${ref(this._scroller)}>
|
||||
<hui-root
|
||||
embedded
|
||||
no-edit
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.route=${this._route(this.view, this._lovelace.config)}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
@view-navigate=${this._viewNavigate}
|
||||
></hui-root>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// A tab, or the back button of a subview, which goes to its `back_path`
|
||||
// when that is one of the dashboard's views, else to the view before it
|
||||
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
|
||||
const view = this.viewOfPath(ev.detail.path);
|
||||
|
||||
if (!ev.detail.back) {
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const target = view === undefined ? this._previousView() : view;
|
||||
|
||||
// Following a `back_path` goes back too, so the view it goes to leaves
|
||||
// the views before
|
||||
if (
|
||||
view !== undefined &&
|
||||
this.isSameView(this._history[this._history.length - 1], view)
|
||||
) {
|
||||
this._history.pop();
|
||||
}
|
||||
|
||||
if (!this.isSameView(target, this.view)) {
|
||||
this._skipHistory = true;
|
||||
this.view = target;
|
||||
}
|
||||
}
|
||||
|
||||
// The last view before that isn't the one shown, else the first view
|
||||
private _previousView(): string | undefined {
|
||||
while (this._history.length) {
|
||||
const previous = this._history.pop();
|
||||
|
||||
if (!this.isSameView(previous, this.view)) {
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
/* Contained, so that the toolbar and background of hui-root, which are
|
||||
fixed to the window in the app, are fixed to the element */
|
||||
:host {
|
||||
height: 600px;
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
.scroller {
|
||||
height: 100%;
|
||||
}
|
||||
.scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-dashboard": HaEmbedDashboard;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { listenMediaQuery } from "../../../src/common/dom/media_query";
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import { reducedMotionContext } from "../../../src/data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
|
||||
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
mockCalendarEvents,
|
||||
WORK_EVENTS,
|
||||
} from "../stubs/calendar";
|
||||
import type { DemoDevice } from "../stubs/device_registry";
|
||||
import { toDeviceRegistryEntry } from "../stubs/device_registry";
|
||||
import { extEntry, newEntry } from "../stubs/entity_registry";
|
||||
import { mockHistory } from "../stubs/history";
|
||||
import { mockIcons } from "../stubs/icons";
|
||||
@@ -40,6 +43,7 @@ import { readScriptData } from "./script-data";
|
||||
interface EmbedProviderData {
|
||||
entities?: EntityInput[];
|
||||
areas?: DemoArea[];
|
||||
devices?: DemoDevice[];
|
||||
calendars?: Record<string, CalendarEventApiData[]>;
|
||||
energy?: Partial<EnergyPreferences>;
|
||||
}
|
||||
@@ -54,8 +58,8 @@ const areaName = (areaId: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
|
||||
* inside it. Cards in the same provider share their entities.
|
||||
* Holds a mocked Home Assistant backend for all embed elements inside it.
|
||||
* Elements in the same provider share their entities.
|
||||
*/
|
||||
@customElement("ha-embed-provider")
|
||||
export class HaEmbedProvider extends LitElement {
|
||||
@@ -67,13 +71,19 @@ export class HaEmbedProvider extends LitElement {
|
||||
* All entities of the backend. Each change replaces all entities.
|
||||
* Also read from a JSON or YAML script child, see `readScriptData`. The
|
||||
* script holds either the list of entities or an object with `entities`,
|
||||
* `areas`, `calendars` and `energy`.
|
||||
* `areas`, `devices`, `calendars` and `energy`.
|
||||
*/
|
||||
@property({ type: Array }) public entities: EntityInput[] = [];
|
||||
|
||||
/** All areas. Without them, an area for each `area_id` of the entities. */
|
||||
/**
|
||||
* All areas. Without them, an area for each `area_id` of the entities and
|
||||
* devices.
|
||||
*/
|
||||
@property({ attribute: false }) public areas?: DemoArea[];
|
||||
|
||||
/** All devices, which entities reference with their `device_id`. */
|
||||
@property({ attribute: false }) public devices?: DemoDevice[];
|
||||
|
||||
/**
|
||||
* Events of calendar entities by entity ID. Calendars without events here
|
||||
* get sample events around today.
|
||||
@@ -155,7 +165,11 @@ export class HaEmbedProvider extends LitElement {
|
||||
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.areas)
|
||||
);
|
||||
// The registry of the entities, without settings such as calendar colors
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
const registryEntries = (currentHass: MockHomeAssistant) =>
|
||||
Object.values(currentHass.entities).map(
|
||||
(entity): EntityRegistryEntry => ({
|
||||
@@ -166,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
|
||||
}),
|
||||
area_id: entity.area_id ?? null,
|
||||
device_id: entity.device_id ?? null,
|
||||
options:
|
||||
this.entities.find((item) => item.entity_id === entity.entity_id)
|
||||
?.options ?? null,
|
||||
})
|
||||
);
|
||||
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
|
||||
@@ -222,6 +239,7 @@ export class HaEmbedProvider extends LitElement {
|
||||
} else if (data) {
|
||||
this.entities = data.entities ?? [];
|
||||
this.areas = data.areas;
|
||||
this.devices = data.devices;
|
||||
this.calendars = data.calendars;
|
||||
this.energy = data.energy;
|
||||
}
|
||||
@@ -238,14 +256,19 @@ export class HaEmbedProvider extends LitElement {
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
if (changedProps.has("entities") || changedProps.has("areas")) {
|
||||
if (
|
||||
changedProps.has("entities") ||
|
||||
changedProps.has("areas") ||
|
||||
changedProps.has("devices")
|
||||
) {
|
||||
const devices = this.devices ?? [];
|
||||
const areas: DemoArea[] =
|
||||
this.areas ??
|
||||
[
|
||||
...new Set(
|
||||
this.entities
|
||||
.map((entity) => entity.area_id)
|
||||
.filter((areaId) => areaId !== undefined)
|
||||
[...this.entities, ...devices].flatMap((item) =>
|
||||
item.area_id === undefined ? [] : [item.area_id]
|
||||
)
|
||||
),
|
||||
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
|
||||
|
||||
@@ -253,11 +276,16 @@ export class HaEmbedProvider extends LitElement {
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = toAreaRegistryEntry(area);
|
||||
});
|
||||
// Before the entities, so that their names can include the area
|
||||
this.hass.updateHass({ areas: areasById });
|
||||
const devicesById: Record<string, DeviceRegistryEntry> = {};
|
||||
devices.forEach((device) => {
|
||||
devicesById[device.id] = toDeviceRegistryEntry(device);
|
||||
});
|
||||
// Before the entities, so that their names can include the area and
|
||||
// device
|
||||
this.hass.updateHass({ areas: areasById, devices: devicesById });
|
||||
|
||||
// Entities that services changed keep their state when only the areas
|
||||
// change
|
||||
// or devices change
|
||||
if (changedProps.has("entities")) {
|
||||
this.hass.addEntities(this.entities, true);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
|
||||
import "../../../src/panels/lovelace/sections/hui-section";
|
||||
import {
|
||||
renderSectionContainer,
|
||||
sectionContainerStyles,
|
||||
} from "../../../src/panels/lovelace/sections/section-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a dashboard section, or a section strategy, with the backend of a
|
||||
* provider, see `EmbedElement`. The section takes the width of the element.
|
||||
*/
|
||||
@customElement("ha-embed-section")
|
||||
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
|
||||
// A title becomes a heading card, as on a dashboard
|
||||
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
|
||||
|
||||
// A section has cards, sections inside it, a strategy that makes them, a
|
||||
// title, or the type of a custom section
|
||||
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
|
||||
return (
|
||||
"cards" in data ||
|
||||
"sections" in data ||
|
||||
"strategy" in data ||
|
||||
"title" in data ||
|
||||
("type" in data && typeof data.type === "string")
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
// The section that hid the element is gone, and the next one starts
|
||||
// visible without telling it
|
||||
if (changedProps.has("config") && !this.config) {
|
||||
this.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// In the container that draws the background of the section, as in a
|
||||
// sections view
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = this._normalizedConfig(this.config);
|
||||
|
||||
return renderSectionContainer(
|
||||
hass,
|
||||
config,
|
||||
html`<hui-section
|
||||
.hass=${hass}
|
||||
.config=${config}
|
||||
@section-visibility-changed=${this._visibilityChanged}
|
||||
></hui-section>`
|
||||
);
|
||||
}
|
||||
|
||||
private _visibilityChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
|
||||
) {
|
||||
// Sections inside the section fire the same event
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hidden = !ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
sectionContainerStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-section": HaEmbedSection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import "../../../src/panels/lovelace/views/hui-view";
|
||||
import "../../../src/panels/lovelace/views/hui-view-background";
|
||||
import "../../../src/panels/lovelace/views/hui-view-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
EmbedLovelaceElement,
|
||||
findViewIndex,
|
||||
isDashboardConfig,
|
||||
} from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard view, a view strategy, or one view of a dashboard or
|
||||
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
|
||||
* `config` holds the view.
|
||||
*/
|
||||
@customElement("ha-embed-view")
|
||||
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
|
||||
/**
|
||||
* A dashboard configuration or dashboard strategy, to show its `view`
|
||||
* instead of `config`. Also read from the `dashboard` key of a script child,
|
||||
* next to `view`.
|
||||
*/
|
||||
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
|
||||
|
||||
private _viewDashboard = memoizeOne(
|
||||
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
|
||||
}
|
||||
|
||||
// A view strategy and a dashboard strategy both have a `strategy` key, so
|
||||
// a dashboard comes in its own key, which no view has
|
||||
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
|
||||
return !("dashboard" in data);
|
||||
}
|
||||
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (!("dashboard" in data)) {
|
||||
super.setScriptData(data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { dashboard } = data;
|
||||
|
||||
if (
|
||||
typeof dashboard === "object" &&
|
||||
dashboard !== null &&
|
||||
isDashboardConfig(dashboard)
|
||||
) {
|
||||
this.dashboard = dashboard;
|
||||
}
|
||||
|
||||
if (
|
||||
"view" in data &&
|
||||
(typeof data.view === "string" || typeof data.view === "number")
|
||||
) {
|
||||
this.view ??= String(data.view);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing for a dashboard without views, such as an empty strategy result.
|
||||
// The view's theme and background, or the dashboard's background, as in
|
||||
// hui-root. Each view gets its own hui-view, as there, so that a view
|
||||
// strategy still generating for the view before can't replace the cards of
|
||||
// this one.
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
if (!this._lovelace || !views?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const index = findViewIndex(views, this.view);
|
||||
const viewConfig = views[index];
|
||||
|
||||
const background =
|
||||
viewConfig.background || this._lovelace.config.background;
|
||||
|
||||
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
|
||||
<hui-view-background .hass=${hass} .background=${background}>
|
||||
</hui-view-background>
|
||||
${keyed(
|
||||
viewConfig,
|
||||
html`<hui-view
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${index}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
></hui-view>`
|
||||
)}
|
||||
</hui-view-container>`;
|
||||
}
|
||||
|
||||
// A fixed background stays inside the element. With a height set on the
|
||||
// element, a panel view fills it, and other views keep their own height.
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
:host {
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
hui-view-container,
|
||||
hui-view {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* The background sits below the view, above the element's own */
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-view": HaEmbedView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import {
|
||||
checkStrategyShouldRegenerate,
|
||||
generateLovelaceDashboardStrategy,
|
||||
} from "../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
// The width below which the app is narrow, see home-assistant-main
|
||||
const NARROW_WIDTH = 870;
|
||||
|
||||
/** The index of the view with this path or index, if there is one. */
|
||||
const viewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view: string
|
||||
): number | undefined => {
|
||||
const byPath = views.findIndex((item) => item.path === view);
|
||||
|
||||
if (byPath !== -1) {
|
||||
return byPath;
|
||||
}
|
||||
|
||||
const index = Number(view);
|
||||
|
||||
return Number.isInteger(index) && index >= 0 && index < views.length
|
||||
? index
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Whether script data is a dashboard: its views, or a strategy for them. */
|
||||
export const isDashboardConfig = (
|
||||
data: ScriptObject
|
||||
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
|
||||
|
||||
/** The index of the view with this path or index, else the first view. */
|
||||
export const findViewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view?: string
|
||||
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render a dashboard, or one of its
|
||||
* views. It generates dashboard strategies, again when the registries they
|
||||
* depend on change, and holds a dashboard that can't be edited or saved.
|
||||
*/
|
||||
export abstract class EmbedLovelaceElement<
|
||||
Config extends ScriptObject,
|
||||
> extends EmbedElement<Config> {
|
||||
/** The path or index of the view to show. Without it, the first view. */
|
||||
@property() public view?: string;
|
||||
|
||||
@state() protected _lovelace?: Lovelace;
|
||||
|
||||
// Narrow like the app at the same width, measured on the element because
|
||||
// it rarely takes the width of the page
|
||||
protected _narrow = new ResizeController(this, {
|
||||
callback: (entries) =>
|
||||
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
|
||||
});
|
||||
|
||||
// The configuration of the dashboard in `_lovelace`, or being generated
|
||||
private _rawConfig?: LovelaceRawConfig;
|
||||
|
||||
// Counts the generations started, so that only the latest one is shown.
|
||||
// Generating again for changed registries keeps the same configuration.
|
||||
private _generation = 0;
|
||||
|
||||
// The backend at the last update, to tell what a strategy depends on changed
|
||||
private _previousHass?: HomeAssistant;
|
||||
|
||||
/** The dashboard to render. The same object until it changes. */
|
||||
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
|
||||
|
||||
/**
|
||||
* The view of the dashboard a path goes to, relative like `batteries` or
|
||||
* with the dashboard like `/maintenance/batteries`, if it has that view.
|
||||
*/
|
||||
protected viewOfPath(path: string | undefined): string | undefined {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
const view = path
|
||||
? decodeURI(new URL(path, "http://embed/").pathname)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
: undefined;
|
||||
|
||||
return views && view !== undefined && viewIndex(views, view) !== undefined
|
||||
? view
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Whether two paths or indexes go to the same view of the dashboard. */
|
||||
protected isSameView(a?: string, b?: string): boolean {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
return views
|
||||
? findViewIndex(views, a) === findViewIndex(views, b)
|
||||
: a === b;
|
||||
}
|
||||
|
||||
// A path to one of the dashboard's views shows that view. Other paths leave
|
||||
// the dashboard where it is.
|
||||
protected navigateTo(path: string) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
const raw = this.rawConfig;
|
||||
const oldHass = this._previousHass;
|
||||
|
||||
this._previousHass = this._hass;
|
||||
|
||||
// Without a configuration, nothing is shown, and a generation still
|
||||
// running is dropped
|
||||
if (!raw) {
|
||||
if (this._rawConfig) {
|
||||
this._rawConfig = undefined;
|
||||
this._lovelace = undefined;
|
||||
this._generation++;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A strategy is only asked whether to generate again when the backend
|
||||
// changed, as in ha-panel-lovelace, not when the generated dashboard
|
||||
// arrives
|
||||
if (
|
||||
raw !== this._rawConfig ||
|
||||
(oldHass &&
|
||||
oldHass !== this._hass &&
|
||||
isStrategyDashboard(raw) &&
|
||||
this._hass.config.state === "RUNNING" &&
|
||||
checkStrategyShouldRegenerate(
|
||||
"dashboard",
|
||||
raw.strategy,
|
||||
oldHass,
|
||||
this._hass
|
||||
))
|
||||
) {
|
||||
this._rawConfig = raw;
|
||||
this._generate(raw, this._hass, ++this._generation);
|
||||
}
|
||||
}
|
||||
|
||||
private async _generate(
|
||||
raw: LovelaceRawConfig,
|
||||
hass: HomeAssistant,
|
||||
generation: number
|
||||
) {
|
||||
const generated = isStrategyDashboard(raw)
|
||||
? await generateLovelaceDashboardStrategy(raw, hass)
|
||||
: raw;
|
||||
|
||||
const config = normalizeLovelaceConfig(generated);
|
||||
|
||||
// A newer generation has started. A generated dashboard has its views,
|
||||
// rather than a strategy.
|
||||
if (generation !== this._generation || isStrategyDashboard(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lovelace = {
|
||||
config,
|
||||
rawConfig: raw,
|
||||
editMode: false,
|
||||
urlPath: null,
|
||||
mode: "generated",
|
||||
locale: hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
// The background of a dashboard, and no insets for a notch or home bar
|
||||
static styles: CSSResultGroup = [
|
||||
embedElementStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
--app-safe-area-inset-top: 0px;
|
||||
--app-safe-area-inset-bottom: 0px;
|
||||
--app-safe-area-inset-left: 0px;
|
||||
--app-safe-area-inset-right: 0px;
|
||||
}
|
||||
.theme {
|
||||
position: relative;
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -15,3 +15,17 @@ export const readScriptData = (element: HTMLElement): unknown => {
|
||||
? load(script.textContent!)
|
||||
: JSON.parse(script.textContent!);
|
||||
};
|
||||
|
||||
/** The data of a script child that holds an object, such as a config. */
|
||||
export type ScriptObject = object;
|
||||
|
||||
/** The object in a script child, see `readScriptData`, if it holds one. */
|
||||
export const readScriptObject = (
|
||||
element: HTMLElement
|
||||
): ScriptObject | undefined => {
|
||||
const data = readScriptData(element);
|
||||
|
||||
return typeof data === "object" && data !== null && !Array.isArray(data)
|
||||
? data
|
||||
: undefined;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unsafeCSS } from "lit";
|
||||
import { css, unsafeCSS } from "lit";
|
||||
import { hex2rgb } from "../../../src/common/color/convert-color";
|
||||
import { darkColorVariables } from "../../../src/resources/theme/color";
|
||||
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
|
||||
@@ -59,3 +59,37 @@ export const embedThemeStyles = unsafeCSS(
|
||||
scopeThemeStyles(themeStyles, ".theme") +
|
||||
`.theme.dark{${darkVariables.join("")}}`
|
||||
);
|
||||
|
||||
/**
|
||||
* The styles of every embed element: the theme, and the page styles of the
|
||||
* app, for the `.theme` wrapper of `EmbedElement`.
|
||||
*/
|
||||
export const embedElementStyles = [
|
||||
embedThemeStyles,
|
||||
css`
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
/* The text styles the app sets on html and body, so that the content does
|
||||
not inherit the text styles of the page that embeds it. */
|
||||
.theme {
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-body);
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
text-align: start;
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
}
|
||||
/* What setDirectionStyles sets on the app for a right-to-left language,
|
||||
here only for the content */
|
||||
.theme[dir="rtl"] {
|
||||
--direction: rtl;
|
||||
--float-start: right;
|
||||
--float-end: left;
|
||||
--scale-direction: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -108,15 +108,24 @@
|
||||
against a mocked backend in the browser. No Home Assistant server is
|
||||
necessary.
|
||||
</p>
|
||||
<p>There are two components:</p>
|
||||
<p>
|
||||
<code><ha-embed-provider></code> holds the mocked backend and its
|
||||
entities. These components render dashboard content with the backend of
|
||||
a provider:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code><ha-embed-card></code> renders one card.</li>
|
||||
<li>
|
||||
<code><ha-embed-provider></code> holds the mocked backend and
|
||||
its entities.
|
||||
<code><ha-embed-section></code> renders one section, or a
|
||||
section strategy.
|
||||
</li>
|
||||
<li>
|
||||
<code><ha-embed-card></code> renders one card. It uses the
|
||||
backend of a provider.
|
||||
<code><ha-embed-view></code> renders one view, a view strategy,
|
||||
or one view of a dashboard.
|
||||
</li>
|
||||
<li>
|
||||
<code><ha-embed-dashboard></code> renders a dashboard, or a
|
||||
dashboard strategy, with its tabs.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -324,6 +333,193 @@
|
||||
</script><!-- htmlmin:ignore -->
|
||||
</ha-embed-card>
|
||||
</div>
|
||||
<p>
|
||||
The other components find their provider the same way, and take the
|
||||
same <code>provider</code> attribute.
|
||||
</p>
|
||||
|
||||
<h2>Sections</h2>
|
||||
<p>
|
||||
<code><ha-embed-section></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><ha-embed-section>
|
||||
<script type="text/yaml">
|
||||
cards:
|
||||
- type: heading
|
||||
heading: Kitchen
|
||||
- type: tile
|
||||
entity: light.kitchen
|
||||
- type: tile
|
||||
entity: switch.kettle
|
||||
</script>
|
||||
</ha-embed-section></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><ha-embed-view></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><ha-embed-view>
|
||||
<script type="text/yaml">
|
||||
type: sections
|
||||
badges:
|
||||
- entity: sensor.outside_temperature
|
||||
sections:
|
||||
- cards: …
|
||||
</script>
|
||||
</ha-embed-view></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><ha-embed-view>
|
||||
<script type="text/yaml">
|
||||
dashboard:
|
||||
strategy:
|
||||
type: custom:my-dashboard
|
||||
view: overview
|
||||
</script>
|
||||
</ha-embed-view></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><ha-embed-dashboard></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><ha-embed-dashboard style="height: 480px">
|
||||
<script type="text/yaml">
|
||||
views:
|
||||
- title: Home
|
||||
path: home
|
||||
cards: …
|
||||
- title: Climate
|
||||
path: climate
|
||||
cards: …
|
||||
</script>
|
||||
</ha-embed-dashboard></code></pre>
|
||||
<ha-embed-dashboard provider="shared" style="height: 480px">
|
||||
<!-- htmlmin:ignore --><script type="text/yaml">
|
||||
views:
|
||||
- title: Home
|
||||
path: home
|
||||
type: sections
|
||||
max_columns: 2
|
||||
sections:
|
||||
- cards:
|
||||
- type: heading
|
||||
heading: Living room
|
||||
- type: tile
|
||||
entity: light.living_room
|
||||
tap_action:
|
||||
action: toggle
|
||||
- type: tile
|
||||
entity: switch.coffee_maker
|
||||
tap_action:
|
||||
action: toggle
|
||||
- cards:
|
||||
- type: heading
|
||||
heading: Outside
|
||||
- type: tile
|
||||
entity: sensor.outside_temperature
|
||||
- title: Climate
|
||||
path: climate
|
||||
type: sections
|
||||
sections:
|
||||
- cards:
|
||||
- type: thermostat
|
||||
entity: climate.hallway
|
||||
</script><!-- htmlmin:ignore -->
|
||||
</ha-embed-dashboard>
|
||||
|
||||
<h2>Light and dark mode</h2>
|
||||
<p>
|
||||
@@ -375,7 +571,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =>
|
||||
<code>provider.hass.states</code>.
|
||||
</p>
|
||||
|
||||
<h2>Areas, calendars and energy</h2>
|
||||
<h2>Areas, devices, calendars and energy</h2>
|
||||
<p>
|
||||
Some cards need data that is not an entity. To set it, put an object in
|
||||
the script tag of the provider instead of the list. The object has the
|
||||
@@ -385,7 +581,15 @@ provider.entities = Object.values(provider.hass.states).map((entity) =>
|
||||
<li>
|
||||
<code>areas</code>: a list of areas, each with an
|
||||
<code>area_id</code> and a <code>name</code>. Without it, each
|
||||
<code>area_id</code> of the entities becomes an area.
|
||||
<code>area_id</code> of the entities and devices becomes an area.
|
||||
</li>
|
||||
<li>
|
||||
<code>devices</code>: a list of devices, each with an
|
||||
<code>id</code> and a <code>name</code>, and optionally a
|
||||
<code>manufacturer</code>, <code>model</code> and
|
||||
<code>area_id</code>. Entities belong to a device with their
|
||||
<code>device_id</code>, and cards and strategies then find the
|
||||
device and its name.
|
||||
</li>
|
||||
<li>
|
||||
<code>calendars</code>: the events of calendar entities, by entity
|
||||
@@ -679,14 +883,92 @@ customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pr
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The ID of the provider to use. Without it, the card uses the
|
||||
provider around it, else the first provider on the page.
|
||||
provider around it, else the first provider on the page. The other
|
||||
components take it too.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3><code><ha-embed-section></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><ha-embed-view></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><ha-embed-dashboard></code></h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>config</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The dashboard configuration or dashboard strategy. A JSON or YAML
|
||||
script child sets the first configuration.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>view</code></td>
|
||||
<td>Attribute and property</td>
|
||||
<td>
|
||||
The path or index of the first view to show. Without it, the first
|
||||
view.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
The mocked backend knows the actions of lights, switches, climate,
|
||||
covers, fans, locks, media players, and more. Actions that open dialogs,
|
||||
such as <code>more-info</code>, do nothing.
|
||||
such as <code>more-info</code>, do nothing. Dashboards can't be edited,
|
||||
and their toolbar only shows their tabs.
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
|
||||
@@ -59,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") },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
/** The fields of a device that the demo devices leave at their default. */
|
||||
export const baseDevice: Omit<
|
||||
DeviceRegistryEntry,
|
||||
| "id"
|
||||
| "name"
|
||||
| "manufacturer"
|
||||
| "model"
|
||||
| "config_entries"
|
||||
| "primary_config_entry"
|
||||
| "entry_type"
|
||||
> = {
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
identifiers: [],
|
||||
model_id: null,
|
||||
labels: [],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export interface DemoDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
area_id?: string;
|
||||
via_device_id?: string;
|
||||
entry_type?: "service";
|
||||
}
|
||||
|
||||
export const toDeviceRegistryEntry = (
|
||||
device: DemoDevice
|
||||
): DeviceRegistryEntry => ({
|
||||
...baseDevice,
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
manufacturer: device.manufacturer ?? null,
|
||||
model: device.model ?? null,
|
||||
area_id: device.area_id ?? null,
|
||||
via_device_id: device.via_device_id ?? null,
|
||||
entry_type: device.entry_type ?? null,
|
||||
config_entries: [],
|
||||
primary_config_entry: null,
|
||||
});
|
||||
|
||||
export const mockDeviceRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DeviceRegistryEntry[] = []
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
import { baseDevice } from "./device_registry";
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.10",
|
||||
"@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": "7.1.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.0.10",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
Generated
+949
-982
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20261028.0"
|
||||
version = "20261028.5"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
+1
-18
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
|
||||
|
||||
const MAX_ROWS = 2;
|
||||
|
||||
// A narrow list, as on a phone or a half width card, fits about one item per
|
||||
// row, so two rows hide most of the legend. 450px is about the widest list a
|
||||
// phone viewport gives.
|
||||
const NARROW_MAX_ROWS = 3;
|
||||
const NARROW_WIDTH = 450;
|
||||
|
||||
// How many items fit in maxRows of a wrapping row, leaving room for the
|
||||
// "more" item after them when not all of them fit.
|
||||
export const fitInRows = (
|
||||
@@ -34,9 +40,10 @@ export const fitInRows = (
|
||||
return widths.length;
|
||||
};
|
||||
|
||||
// Fits the items of a wrapping legend list into two rows. The list keeps its
|
||||
// "more" item rendered with the legend-more class, and items past the fit
|
||||
// stay rendered out of the flow, so both can always be measured.
|
||||
// Fits the items of a wrapping legend list into two rows, three when it is
|
||||
// narrow. The list keeps its "more" item rendered with the legend-more class,
|
||||
// and items past the fit stay rendered out of the flow, so both can always be
|
||||
// measured.
|
||||
export class LegendFitController {
|
||||
// Items to show while collapsed, undefined until the list is measured
|
||||
public fit?: number;
|
||||
@@ -118,7 +125,7 @@ export class LegendFitController {
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
MAX_ROWS
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
this.fit = fit;
|
||||
|
||||
@@ -26,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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,20 +157,3 @@ export const findEntities = (
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
sources.states,
|
||||
sources.entities,
|
||||
sources.devices,
|
||||
sources.areas,
|
||||
sources.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
};
|
||||
@@ -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>';
|
||||
'© <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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
AREA_CONTROLS_BUTTONS,
|
||||
getAreaControlEntities,
|
||||
getAreaEntityIds,
|
||||
type AreaControlDomain,
|
||||
} from "../data/area/area_controls";
|
||||
import {
|
||||
@@ -148,15 +149,20 @@ 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,
|
||||
areaId,
|
||||
excludeEntities
|
||||
AREA_CONTROL_DOMAINS
|
||||
);
|
||||
|
||||
const items: (AreaControlPickerItem | string)[] = [];
|
||||
|
||||
@@ -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": "",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../../data/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { UiClockDateFormatSelector } from "../../data/selector";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PRESETS,
|
||||
formatClockCardDatePreset,
|
||||
getClockCardDatePresetParts,
|
||||
isClockCardDatePreset,
|
||||
} from "../../panels/lovelace/cards/clock/clock-date-format";
|
||||
import type { ClockCardDatePreset } from "../../panels/lovelace/cards/types";
|
||||
import "../ha-clock-date-format-picker";
|
||||
import "../ha-select";
|
||||
import type { HaSelectOption, HaSelectSelectEvent } from "../ha-select";
|
||||
|
||||
type ClockDateFormatMode = ClockCardDatePreset | "custom";
|
||||
|
||||
@customElement("ha-selector-ui_clock_date_format")
|
||||
export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
@@ -35,130 +17,17 @@ export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _custom = false;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
changedProperties.has("value") &&
|
||||
Array.isArray(this.value) &&
|
||||
this.value.length
|
||||
) {
|
||||
this._custom = true;
|
||||
}
|
||||
}
|
||||
|
||||
private get _mode(): ClockDateFormatMode | undefined {
|
||||
if (isClockCardDatePreset(this.value)) {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
return this._custom ? "custom" : undefined;
|
||||
}
|
||||
|
||||
private _options = memoizeOne(
|
||||
(
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
serverTimeZone: string
|
||||
): HaSelectOption[] => {
|
||||
const now = new Date();
|
||||
const timeZone = resolveTimeZone(i18n.locale.time_zone, serverTimeZone);
|
||||
|
||||
return [
|
||||
...CLOCK_CARD_DATE_PRESETS.map((preset) => ({
|
||||
value: preset,
|
||||
label: i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.presets.${preset}`
|
||||
),
|
||||
secondary: formatClockCardDatePreset(
|
||||
now,
|
||||
preset,
|
||||
i18n.locale.language,
|
||||
timeZone
|
||||
),
|
||||
})),
|
||||
{
|
||||
value: "custom",
|
||||
label: i18n.localize(
|
||||
"ui.panel.lovelace.editor.card.clock.date.presets.custom"
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const mode = this._mode;
|
||||
|
||||
return html`
|
||||
<ha-select
|
||||
<ha-clock-date-format-picker
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.value=${mode}
|
||||
.options=${this._options(this._i18n, this._hassConfig.config.time_zone)}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.clearable=${!this.required}
|
||||
@selected=${this._modeSelected}
|
||||
></ha-select>
|
||||
${
|
||||
mode === "custom"
|
||||
? html`
|
||||
<ha-clock-date-format-picker
|
||||
.value=${this.value}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-clock-date-format-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
></ha-clock-date-format-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _modeSelected(
|
||||
ev: HaSelectSelectEvent<ClockDateFormatMode, true>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const mode = ev.detail.value;
|
||||
const currentValue = this.value;
|
||||
|
||||
this._custom = mode === "custom";
|
||||
|
||||
if (mode === "custom") {
|
||||
if (isClockCardDatePreset(currentValue)) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: getClockCardDatePresetParts(
|
||||
currentValue,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", { value: mode });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import TabGroup from "@home-assistant/webawesome/dist/components/tab-group/tab-group";
|
||||
import { css, type CSSResultGroup, type PropertyValues } from "lit";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { SortableEvent } from "sortablejs";
|
||||
import { DragScrollController } from "../common/controllers/drag-scroll-controller";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { SortableInstance } from "../resources/sortable";
|
||||
|
||||
const TOUCH_DRAG_DELAY_MS = 300;
|
||||
|
||||
const AUTO_SCROLL_SENSITIVITY = 30;
|
||||
|
||||
@customElement("ha-tab-group")
|
||||
export class HaTabGroup extends TabGroup {
|
||||
@@ -20,199 +13,24 @@ export class HaTabGroup extends TabGroup {
|
||||
|
||||
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
|
||||
|
||||
/**
|
||||
* Allow reordering tabs by dragging or with Alt+ArrowUp/ArrowDown. Fires
|
||||
* `item-moved`; the owner applies the new order.
|
||||
*/
|
||||
@property({ type: Boolean }) public sortable = false;
|
||||
|
||||
/** Drag handle that starts a touch drag without the long press. */
|
||||
@property({ attribute: "handle-selector" }) public handleSelector?: string;
|
||||
|
||||
private _sortable?: SortableInstance;
|
||||
|
||||
private _sortPlaceholder?: Comment;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
|
||||
// which browsers use for back/forward navigation.
|
||||
this.addEventListener("keydown", this._handleKeyDown, true);
|
||||
if (this.hasUpdated && this.sortable) {
|
||||
this._createSortable();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("keydown", this._handleKeyDown, true);
|
||||
this._destroySortable();
|
||||
}
|
||||
|
||||
protected override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("sortable")) {
|
||||
// Dragging the tab bar to scroll would compete with dragging a tab.
|
||||
this._dragScrollController.enabled = !this.sortable;
|
||||
if (this.sortable) {
|
||||
this._createSortable();
|
||||
} else {
|
||||
this._destroySortable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _createSortable() {
|
||||
if (this._sortable) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const Sortable = (await import("../resources/sortable")).default;
|
||||
if (this._sortable || !this.sortable || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// supportPointer is missing from the SortableJS typings.
|
||||
const options: SortableInstance.Options & { supportPointer?: boolean } = {
|
||||
draggable: this.tabTag,
|
||||
// SortableJS guesses vertical from the tabs being display: block.
|
||||
direction:
|
||||
this.placement === "start" || this.placement === "end"
|
||||
? "vertical"
|
||||
: "horizontal",
|
||||
// The tab bar scrolls inside the shadow root, where SortableJS can't find it.
|
||||
scroll: this.nav,
|
||||
// Force the autoscroll fallback because it works better than the native one
|
||||
forceAutoScrollFallback: true,
|
||||
scrollSensitivity: AUTO_SCROLL_SENSITIVITY,
|
||||
scrollSpeed: 20,
|
||||
animation: 150,
|
||||
// Keep swiping on touch screens for scrolling the tab bar.
|
||||
delay: TOUCH_DRAG_DELAY_MS,
|
||||
delayOnTouchOnly: true,
|
||||
// A scrolling swipe ends in pointercancel, after which SortableJS
|
||||
// ignores the next drag. Touch events end with touchend instead.
|
||||
supportPointer: false,
|
||||
onStart: this._handleSortableStart,
|
||||
onEnd: this._handleSortableEnd,
|
||||
onUpdate: this._handleSortableUpdate,
|
||||
};
|
||||
this._sortable = new Sortable(this, options);
|
||||
// Capture, so the delay is set before SortableJS reads it.
|
||||
this.addEventListener("touchstart", this._handleTouchStart, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
private _destroySortable() {
|
||||
this._sortable?.destroy();
|
||||
this._sortable = undefined;
|
||||
this.removeEventListener("touchstart", this._handleTouchStart, true);
|
||||
this._removeRtlAutoScrollListeners();
|
||||
}
|
||||
|
||||
private _handleTouchStart = (ev: TouchEvent) => {
|
||||
const onHandle =
|
||||
!!this.handleSelector &&
|
||||
ev
|
||||
.composedPath()
|
||||
.some(
|
||||
(el) => el instanceof Element && el.matches(this.handleSelector!)
|
||||
);
|
||||
this._sortable!.option("delay", onHandle ? 0 : TOUCH_DRAG_DELAY_MS);
|
||||
};
|
||||
|
||||
private _handleSortableStart = (evt: SortableEvent) => {
|
||||
// Put the tab back after dropping, the owner renders the new order.
|
||||
this._sortPlaceholder = document.createComment("sort-placeholder");
|
||||
evt.item.after(this._sortPlaceholder);
|
||||
// Touch drags add a copy of the tab to this element, which the tab group
|
||||
// would show and select as an extra tab. The dimmed tab shows the position.
|
||||
this.querySelector(":scope > .sortable-fallback")?.remove();
|
||||
if (getComputedStyle(this.nav).direction !== "rtl") {
|
||||
return;
|
||||
}
|
||||
// Capture, so this runs before SortableJS checks for auto scroll.
|
||||
const options = { capture: true, passive: true };
|
||||
window.addEventListener("dragover", this._unblockRtlAutoScroll, options);
|
||||
window.addEventListener("touchmove", this._unblockRtlAutoScroll, options);
|
||||
};
|
||||
|
||||
private _removeRtlAutoScrollListeners() {
|
||||
window.removeEventListener("dragover", this._unblockRtlAutoScroll, true);
|
||||
window.removeEventListener("touchmove", this._unblockRtlAutoScroll, true);
|
||||
}
|
||||
|
||||
// SortableJS only auto scrolls left while scrollLeft isn't 0, but in RTL the
|
||||
// tab bar starts at 0 and scrolls left into negative values.
|
||||
private _unblockRtlAutoScroll = (ev: DragEvent | TouchEvent) => {
|
||||
const x = "touches" in ev ? ev.touches[0]?.clientX : ev.clientX;
|
||||
if (x === undefined || this.nav.scrollLeft !== 0) {
|
||||
return;
|
||||
}
|
||||
const left = this.nav.getBoundingClientRect().left;
|
||||
if (Math.abs(x - left) <= AUTO_SCROLL_SENSITIVITY) {
|
||||
this.nav.scrollLeft = -1;
|
||||
}
|
||||
};
|
||||
|
||||
private _handleSortableEnd = (evt: SortableEvent) => {
|
||||
this._removeRtlAutoScrollListeners();
|
||||
this._sortPlaceholder?.replaceWith(evt.item);
|
||||
this._sortPlaceholder = undefined;
|
||||
};
|
||||
|
||||
private _handleSortableUpdate = (evt: SortableEvent) => {
|
||||
fireEvent(this, "item-moved", {
|
||||
oldIndex: evt.oldIndex!,
|
||||
newIndex: evt.newIndex!,
|
||||
});
|
||||
};
|
||||
|
||||
private _handleKeyDown = (event: KeyboardEvent) => {
|
||||
// The tab group cancels Enter and Space, so buttons inside a tab would not
|
||||
// be pressed.
|
||||
if (
|
||||
(event.key === "Enter" || event.key === " ") &&
|
||||
event.composedPath()[0] instanceof HTMLButtonElement &&
|
||||
(event.target as HTMLElement).closest(this.tabTag)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.altKey || event.metaKey) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (
|
||||
this.sortable &&
|
||||
event.altKey &&
|
||||
!(event.ctrlKey || event.metaKey) &&
|
||||
!event.shiftKey &&
|
||||
(event.key === "ArrowUp" || event.key === "ArrowDown")
|
||||
) {
|
||||
this._moveTabWithKeyboard(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Up and down follow the tab order, so they work the same in RTL.
|
||||
private _moveTabWithKeyboard(event: KeyboardEvent) {
|
||||
const tab = (event.target as HTMLElement).closest(this.tabTag);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
const tabs = [...this.children].filter(
|
||||
(el) => el.localName === this.tabTag
|
||||
);
|
||||
const oldIndex = tabs.indexOf(tab);
|
||||
const newIndex = oldIndex + (event.key === "ArrowUp" ? -1 : 1);
|
||||
event.preventDefault();
|
||||
if (oldIndex === -1 || newIndex < 0 || newIndex >= tabs.length) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "item-moved", { oldIndex, newIndex });
|
||||
}
|
||||
|
||||
protected override handleClick(event: MouseEvent) {
|
||||
if (this._dragScrollController.scrolled) {
|
||||
return;
|
||||
@@ -249,10 +67,6 @@ export class HaTabGroup extends TabGroup {
|
||||
margin-inline-start: calc(-1 * var(--track-width));
|
||||
}
|
||||
|
||||
::slotted(.sortable-ghost) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.scroll-button::part(base):hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
Loaded 100 of 358 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user