mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 21:45:16 +00:00
Compare commits
190
Commits
20261028.5
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebc57d89a | ||
|
|
63b474f43c | ||
|
|
d5cc973c22 | ||
|
|
00e76021ce | ||
|
|
fb88d66421 | ||
|
|
23b161d257 | ||
|
|
123a32dd81 | ||
|
|
4dc713abe6 | ||
|
|
b9182e3ea6 | ||
|
|
9bd57fa122 | ||
|
|
4af7503112 | ||
|
|
984e63dcd0 | ||
|
|
f90ed06080 | ||
|
|
b176b8226b | ||
|
|
3f3e9297f7 | ||
|
|
83028028e8 | ||
|
|
37d7c6144c | ||
|
|
f469f3eeae | ||
|
|
069e87c913 | ||
|
|
46bdbee8f3 | ||
|
|
68def911a6 | ||
|
|
a8fdb99317 | ||
|
|
3d1bf61683 | ||
|
|
5cfdff0e21 | ||
|
|
54cefeb816 | ||
|
|
4458205273 | ||
|
|
0c2de46f11 | ||
|
|
6bef900455 | ||
|
|
438bfa2710 | ||
|
|
96864e951a | ||
|
|
a1355e5116 | ||
|
|
ae4366020c | ||
|
|
7f73d29b2f | ||
|
|
ebf453ccc7 | ||
|
|
a0d9afe695 | ||
|
|
c42d048736 | ||
|
|
2f62ed5f73 | ||
|
|
8b6caeecdd | ||
|
|
4800b4f7e9 | ||
|
|
c7afc3391c | ||
|
|
3e9490e547 | ||
|
|
7a1cd6bbfe | ||
|
|
a2c3147d33 | ||
|
|
7039e3ad9f | ||
|
|
e97745d7d5 | ||
|
|
790c957405 | ||
|
|
f740091579 | ||
|
|
f76c88dd9f | ||
|
|
a428d61cdb | ||
|
|
fcf7eeae46 | ||
|
|
6d2817b7cf | ||
|
|
640e4e2f94 | ||
|
|
6670139c02 | ||
|
|
93eb44b041 | ||
|
|
ba6067f048 | ||
|
|
cd866a497a | ||
|
|
98a8483426 | ||
|
|
33af67bafe | ||
|
|
027d9578ec | ||
|
|
5fef8f3a68 | ||
|
|
18e65c1940 | ||
|
|
29906d6f83 | ||
|
|
952c227370 | ||
|
|
173c60e57b | ||
|
|
ef134330f3 | ||
|
|
c75386ab33 | ||
|
|
61b433f912 | ||
|
|
16ed504c66 | ||
|
|
e9550ffe52 | ||
|
|
2c6cf8e45b | ||
|
|
0dcfc247a7 | ||
|
|
2ab1f187c5 | ||
|
|
56d6f1c571 | ||
|
|
f169ae79f5 | ||
|
|
599089a2d3 | ||
|
|
2af451338f | ||
|
|
2ba9a1fc61 | ||
|
|
96e525f7a3 | ||
|
|
c42dac197f | ||
|
|
8fbae6346d | ||
|
|
2a69518dba | ||
|
|
960bf08c84 | ||
|
|
6c2bc65f07 | ||
|
|
e52547e374 | ||
|
|
7474e3e342 | ||
|
|
8dbd1527d9 | ||
|
|
d9d849c843 | ||
|
|
4f73b43912 | ||
|
|
a6fb28dfef | ||
|
|
d4ad00c509 | ||
|
|
2fdef6883c | ||
|
|
598034b1d3 | ||
|
|
4ad7c7a96a | ||
|
|
14f66ca105 | ||
|
|
831dbeb975 | ||
|
|
aecf17aae4 | ||
|
|
037d3dad01 | ||
|
|
e80c2e55b5 | ||
|
|
581b6abdd8 | ||
|
|
4c43c7446a | ||
|
|
0f73094afd | ||
|
|
53f8e6c8cb | ||
|
|
5c9fc8723c | ||
|
|
8995004feb | ||
|
|
4fa5991889 | ||
|
|
9925c10d03 | ||
|
|
7fc68028ea | ||
|
|
6136ec4909 | ||
|
|
d8a366b3d4 | ||
|
|
03e5814a57 | ||
|
|
9421cda8cf | ||
|
|
25490642f9 | ||
|
|
b01892a128 | ||
|
|
7baeed0cf6 | ||
|
|
f1a9e2f69a | ||
|
|
fdfc00f3d7 | ||
|
|
ee33e4bfde | ||
|
|
39754807cc | ||
|
|
752251b1b6 | ||
|
|
95c613b098 | ||
|
|
a970c5c7f6 | ||
|
|
78f6a55e3c | ||
|
|
563b90c773 | ||
|
|
bf996a2521 | ||
|
|
f04ce740a1 | ||
|
|
4a455cd883 | ||
|
|
6ae0d2ac72 | ||
|
|
4b156e5666 | ||
|
|
ede6d92ab6 | ||
|
|
5a4e664597 | ||
|
|
40d2739a5f | ||
|
|
52f632e4c2 | ||
|
|
5ac2316f79 | ||
|
|
22ff94a5d6 | ||
|
|
7d2fa3cf5c | ||
|
|
b8f0213d16 | ||
|
|
d4e0da9e21 | ||
|
|
95c75bdd5b | ||
|
|
36d57f1bba | ||
|
|
95d4e7705f | ||
|
|
87babb1a3b | ||
|
|
2966588338 | ||
|
|
7e07d3b5c4 | ||
|
|
1b9d866977 | ||
|
|
0fc4a042c5 | ||
|
|
b865ca9357 | ||
|
|
f817d76b73 | ||
|
|
c695f577ff | ||
|
|
4cb82bf40b | ||
|
|
449e7f8501 | ||
|
|
7b0d350fb7 | ||
|
|
ed2cc613e1 | ||
|
|
79f193f0d8 | ||
|
|
149f24255b | ||
|
|
c1a82bfe7f | ||
|
|
5083578c1e | ||
|
|
5f11dc4580 | ||
|
|
27489f188e | ||
|
|
97fffc3b43 | ||
|
|
a30193b8ef | ||
|
|
cf063dc19f | ||
|
|
5db829b7be | ||
|
|
421f8b1986 | ||
|
|
a140eedc6e | ||
|
|
2933568658 | ||
|
|
29faeaf7fa | ||
|
|
95ba532ceb | ||
|
|
352ecb9d6d | ||
|
|
3f73049a3d | ||
|
|
50ad0c6cd0 | ||
|
|
78f95bbb06 | ||
|
|
6134056776 | ||
|
|
b79c6350ec | ||
|
|
8148041ddc | ||
|
|
4265624c0e | ||
|
|
ed4179de8f | ||
|
|
e407a1b2dd | ||
|
|
e851101a93 | ||
|
|
d43e1ef4cc | ||
|
|
f699019994 | ||
|
|
fe90b0339d | ||
|
|
3b5ba802f6 | ||
|
|
b0c6e7f564 | ||
|
|
658a23724e | ||
|
|
f859183628 | ||
|
|
53dc14f413 | ||
|
|
f2523095ff | ||
|
|
ef5c04b67f | ||
|
|
10ed6f709e | ||
|
|
c22de3bc7c |
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, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
|
||||
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, loading states, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
|
||||
---
|
||||
|
||||
# HA Frontend Components
|
||||
@@ -91,6 +91,36 @@ html`
|
||||
`;
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
|
||||
|
||||
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
|
||||
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
|
||||
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
|
||||
|
||||
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
|
||||
|
||||
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
|
||||
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
|
||||
- Don't fade in the loaded content itself.
|
||||
|
||||
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
|
||||
|
||||
```ts
|
||||
html`
|
||||
<ha-fade-in .duration=${300} easing="ease-in">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label=${this._i18n.localize("ui.common.loading")}
|
||||
>
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
</div>
|
||||
</ha-fade-in>
|
||||
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
|
||||
`;
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
|
||||
|
||||
@@ -10,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
|
||||
@@ -12,3 +12,7 @@
|
||||
|
||||
demo/public/api/camera_proxy_stream/* binary
|
||||
demo/public/api/media_player_proxy/* binary
|
||||
|
||||
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
|
||||
# on a conflict, restore it from the base branch and run `gh actions-lock`.
|
||||
.github/workflows/actions.lock linguist-language=YAML -merge
|
||||
@@ -51,8 +51,8 @@
|
||||
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue or discussion:
|
||||
- Link to documentation pull request:
|
||||
- Link to developer documentation pull request:
|
||||
- Link to [documentation][docs-repository] pull request:
|
||||
- Link to [contributors documentation][contributors-repository] pull request:
|
||||
- Link to backend pull request:
|
||||
|
||||
## Checklist
|
||||
@@ -63,7 +63,8 @@
|
||||
for before merging your code.
|
||||
|
||||
AI tools are welcome, but contributors are responsible for *fully*
|
||||
understanding the code before submitting a PR.
|
||||
understanding the code before submitting a PR. Please follow our AI policy:
|
||||
https://contributors.home-assistant.io/start/ai-policy/
|
||||
-->
|
||||
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
|
||||
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
|
||||
@@ -0,0 +1,6 @@
|
||||
paths:
|
||||
.github/workflows/**/*.{yml,yaml}:
|
||||
ignore:
|
||||
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
|
||||
# https://github.com/rhysd/actionlint/issues/711
|
||||
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
|
||||
@@ -16,6 +16,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
@@ -24,7 +25,7 @@ runs:
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
@@ -35,6 +36,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -28,6 +28,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Verify actions lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify actions lockfile
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_ACTIONS_LOCK_VERSION: v0.4.1
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
|
||||
- name: Verify lockfile
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh actions-lock --verify
|
||||
@@ -0,0 +1,190 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/actionlint.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
'.github/workflows/actions-lock.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
'.github/workflows/cast_deployment.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/ci.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
'.github/workflows/codeql-analysis.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
|
||||
'.github/workflows/copilot-setup-steps.yml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/demo_deployment.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/dependency-review.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
'.github/workflows/design_deployment.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/design_preview.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/e2e.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
'.github/workflows/labeler.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
|
||||
'.github/workflows/lock.yaml':
|
||||
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
|
||||
'.github/workflows/nightly.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
||||
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
'.github/workflows/pull-request-standards.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
'.github/workflows/relative-ci.yaml':
|
||||
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
|
||||
'.github/workflows/release-drafter.yaml':
|
||||
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
|
||||
'.github/workflows/release.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
||||
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
|
||||
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
|
||||
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
|
||||
'.github/workflows/restrict-task-creation.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
'.github/workflows/stale.yaml':
|
||||
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
|
||||
'.github/workflows/sync-device-classes.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
'.github/workflows/sync-sensor-entity-constants.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
'.github/workflows/translations.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
dependencies:
|
||||
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
|
||||
ref: 'v6.1.0'
|
||||
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
owner_id: 44036562
|
||||
repo_id: 215566462
|
||||
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
|
||||
ref: 'v5.0.0'
|
||||
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
|
||||
owner_id: 44036562
|
||||
repo_id: 476372928
|
||||
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
|
||||
ref: 'v8.0.2'
|
||||
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
|
||||
owner_id: 44036562
|
||||
repo_id: 192626254
|
||||
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
|
||||
ref: 'v9.0.0'
|
||||
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
owner_id: 44036562
|
||||
repo_id: 205262760
|
||||
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
|
||||
ref: 'v7.0.0'
|
||||
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
|
||||
owner_id: 44036562
|
||||
repo_id: 177139928
|
||||
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
|
||||
ref: 'v7.1.0'
|
||||
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
owner_id: 44036562
|
||||
repo_id: 189476904
|
||||
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
|
||||
ref: 'v7.1.0'
|
||||
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625525
|
||||
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
|
||||
ref: 'v6.2.0'
|
||||
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625525
|
||||
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
|
||||
ref: 'v11.0.0'
|
||||
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
|
||||
owner_id: 44036562
|
||||
repo_id: 200437066
|
||||
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
|
||||
ref: 'v7.0.2'
|
||||
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625955
|
||||
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
|
||||
ref: 'v6.0.2'
|
||||
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
|
||||
owner_id: 7513068
|
||||
repo_id: 113918130
|
||||
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
|
||||
ref: 'v4.39.1'
|
||||
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
|
||||
owner_id: 9919
|
||||
repo_id: 259445878
|
||||
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
|
||||
ref: 'master'
|
||||
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
|
||||
owner_id: 13844975
|
||||
repo_id: 173703505
|
||||
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
|
||||
ref: '2026.07.0'
|
||||
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
|
||||
owner_id: 13844975
|
||||
repo_id: 184234812
|
||||
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
|
||||
ref: 'v8.1.1'
|
||||
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
owner_id: 18365890
|
||||
repo_id: 197176365
|
||||
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
|
||||
ref: 'v1.14.3'
|
||||
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
|
||||
owner_id: 647025
|
||||
repo_id: 178055147
|
||||
uses:
|
||||
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
|
||||
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
|
||||
ref: 'v3.2.4'
|
||||
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
|
||||
owner_id: 43522883
|
||||
repo_id: 398376354
|
||||
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
|
||||
ref: 'v7.10.0'
|
||||
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
|
||||
owner_id: 58292376
|
||||
repo_id: 139022359
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Development
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -30,17 +30,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -62,17 +62,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -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@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -22,10 +22,45 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
# These steps mirror .github/actions/setup with node-modules-cache enabled.
|
||||
# Copilot runs this workflow in a context that can't resolve `$/`
|
||||
# self-repository actions, so the steps are inlined here.
|
||||
- name: Restore complete dependency tree
|
||||
id: dependency-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
node-modules-cache: true
|
||||
path: |
|
||||
node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
if: steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
if: steps.pnpm-store.outputs.path
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Skip fetching nightly translations
|
||||
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Development
|
||||
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
@@ -31,17 +31,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -62,17 +62,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
uses: $/.github/actions/prepare-dependencies
|
||||
with:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-demo-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -97,12 +97,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -128,12 +128,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
|
||||
|
||||
@@ -31,15 +31,15 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
cache: false
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
cache: false
|
||||
|
||||
@@ -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, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, loading states, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
|
||||
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
|
||||
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
|
||||
|
||||
@@ -68,8 +68,42 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
// Its exports define the components source aliases below
|
||||
path.join(__dirname, "..", "packages", "components", "package.json"),
|
||||
];
|
||||
|
||||
// Resolve @home-assistant/components entry points to their sources, so the package
|
||||
// needs no build step during development. The entry points are read from the
|
||||
// "ha-source" export condition of the package; subpath patterns are expanded
|
||||
// for every element, i.e. every directory with a global.ts, like "card".
|
||||
const COMPONENTS_PACKAGE_DIR = path.join(
|
||||
paths.root_dir,
|
||||
"packages",
|
||||
"components"
|
||||
);
|
||||
const COMPONENTS_SRC_DIR = path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
"src",
|
||||
"components"
|
||||
);
|
||||
const componentElements = fs
|
||||
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
|
||||
.filter((file) => path.basename(file) === "global.ts")
|
||||
.map((file) => path.dirname(file).split(path.sep).join("/"));
|
||||
const componentsSourceAliases = Object.fromEntries(
|
||||
Object.entries(require("../packages/components/package.json").exports)
|
||||
.filter(([, target]) => target["ha-source"])
|
||||
.flatMap(([subpath, target]) =>
|
||||
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
|
||||
`${path.posix.join("@home-assistant/components", name ? subpath.replaceAll("*", name) : subpath)}$`,
|
||||
path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
name ? target["ha-source"].replaceAll("*", name) : target["ha-source"]
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
@@ -431,6 +465,7 @@ const createRspackConfig = ({
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
...componentsSourceAliases,
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"background_color": "#FFFFFF",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
|
||||
"dir": "ltr",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="twitter:title" content="Home Assistant Cast" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<p>
|
||||
Home Assistant Cast allows you to show your Home Assistant data on a
|
||||
Chromecast device and allows you to interact with Home Assistant on
|
||||
Google Assistant devices with a screen.
|
||||
Google Home devices with a screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ActionDetail } from "@material/mwc-list/mwc-list";
|
||||
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
|
||||
import type { Auth, Connection } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../../src/cast/cast_manager";
|
||||
import {
|
||||
@@ -14,12 +13,14 @@ import {
|
||||
enableWrite,
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
import "../../../../src/components/ha-list-item";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import {
|
||||
getLegacyLovelaceCollection,
|
||||
getLovelaceCollection,
|
||||
@@ -53,6 +54,9 @@ class HcCast extends LitElement {
|
||||
`
|
||||
: undefined;
|
||||
|
||||
const views = this.lovelaceViews ?? [{ title: "Home" }];
|
||||
const hasIcons = views.some((view) => view.icon);
|
||||
|
||||
return html`
|
||||
<hc-layout .auth=${this.auth} .connection=${this.connection}>
|
||||
${
|
||||
@@ -93,45 +97,41 @@ class HcCast extends LitElement {
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<div class="section-header">PICK A VIEW</div>
|
||||
<ha-list @action=${this._handlePickView} activatable>
|
||||
${(
|
||||
this.lovelaceViews ?? [
|
||||
{
|
||||
title: "Home",
|
||||
},
|
||||
]
|
||||
).map(
|
||||
<div class="section-header" id="pick-view-header">
|
||||
PICK A VIEW
|
||||
</div>
|
||||
<ha-list-selectable
|
||||
controlled
|
||||
aria-labelledby="pick-view-header"
|
||||
>
|
||||
${views.map(
|
||||
(view, idx) => html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.activated=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
<ha-list-item-option
|
||||
data-index=${idx}
|
||||
@click=${this._handlePickView}
|
||||
.headline=${view.title || view.path || "Unnamed view"}
|
||||
.selected=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
>
|
||||
${view.title || view.path || "Unnamed view"}
|
||||
${
|
||||
view.icon
|
||||
? html`
|
||||
<ha-icon
|
||||
!hasIcons
|
||||
? nothing
|
||||
: view.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
.icon=${view.icon}
|
||||
slot="graphic"
|
||||
></ha-icon>
|
||||
`
|
||||
: html`<ha-svg-icon
|
||||
slot="item-icon"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</ha-list-item>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}</ha-list
|
||||
>
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -194,14 +194,6 @@ class HcCast extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
}
|
||||
|
||||
private async _handleSkipSaveTokens() {
|
||||
this.askWrite = false;
|
||||
}
|
||||
@@ -215,8 +207,13 @@ class HcCast extends LitElement {
|
||||
this.castManager.requestSession();
|
||||
}
|
||||
|
||||
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
|
||||
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
|
||||
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
|
||||
// for the selected view, so the current view can still be cast again.
|
||||
private async _handlePickView(
|
||||
ev: HASSDomCurrentTargetEvent<HaListItemOption>
|
||||
) {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
const path = this.lovelaceViews?.[index]?.path ?? index;
|
||||
await ensureConnectedCastSession(this.castManager!, this.auth!);
|
||||
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
|
||||
}
|
||||
@@ -271,16 +268,11 @@ class HcCast extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-list-item ha-icon,
|
||||
ha-list-item ha-svg-icon {
|
||||
padding: 12px;
|
||||
ha-list-item-option ha-icon,
|
||||
ha-list-item-option ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
:host([hide-icons]) ha-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const translateErr = (err) =>
|
||||
const INTRO = html`
|
||||
<p>
|
||||
Home Assistant Cast allows you to cast your Home Assistant installation to
|
||||
Chromecast video devices and to Google Assistant devices with a screen.
|
||||
Chromecast video devices and to Google Home devices with a screen.
|
||||
</p>
|
||||
<p>
|
||||
For more information, see the
|
||||
|
||||
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
|
||||
|
||||
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
{
|
||||
type: "markdown",
|
||||
title: "Home Assistant Cast",
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
|
||||
touchSupported
|
||||
? "\n\nYou are able to interact with this demo using the touch screen."
|
||||
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
|
||||
|
||||
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
// We received a play media command, hide Lovelace and show media player
|
||||
showMediaPlayer();
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -59,6 +59,10 @@ const REGISTRY_ENTRIES = [
|
||||
export const infraredFixtures: ConnectivityFixtures = {
|
||||
components: ["infrared"],
|
||||
manifests: [
|
||||
manifest("infrared", "Infrared", {
|
||||
config_flow: false,
|
||||
integration_type: "entity",
|
||||
}),
|
||||
manifest("broadlink", "Broadlink", {
|
||||
integration_type: "hub",
|
||||
iot_class: "local_polling",
|
||||
|
||||
@@ -42,7 +42,13 @@ const REGISTRY_ENTRIES = [
|
||||
export const radioFrequencyFixtures: ConnectivityFixtures = {
|
||||
components: ["radio_frequency"],
|
||||
commands: ["radio_frequency/"],
|
||||
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
|
||||
manifests: [
|
||||
manifest("radio_frequency", "Radio frequency", {
|
||||
config_flow: false,
|
||||
integration_type: "entity",
|
||||
}),
|
||||
manifest("esphome", "ESPHome", { integration_type: "device" }),
|
||||
],
|
||||
configEntries: [
|
||||
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
|
||||
],
|
||||
|
||||
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
device_consumption_gas: [
|
||||
{
|
||||
stat_consumption: "sensor.gas_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.gas_heat_pump",
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
device_consumption_gas: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
|
||||
@@ -160,6 +160,26 @@ export const energyEntities = () =>
|
||||
unit_of_measurement: "L",
|
||||
},
|
||||
},
|
||||
"sensor.gas_kitchen": {
|
||||
entity_id: "sensor.gas_kitchen",
|
||||
state: "100",
|
||||
attributes: {
|
||||
friendly_name: "Gas kitchen",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.gas_heat_pump": {
|
||||
entity_id: "sensor.gas_heat_pump",
|
||||
state: "500",
|
||||
attributes: {
|
||||
friendly_name: "Gas heat pump",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.energy_car": {
|
||||
entity_id: "sensor.energy_car",
|
||||
state: "4",
|
||||
|
||||
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
100,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
500,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
|
||||
@@ -301,6 +301,14 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Published as native ESM, where package subpath imports such as
|
||||
// "lit/decorators.js" need their extension.
|
||||
files: ["packages/*/src/**/*.ts"],
|
||||
rules: {
|
||||
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -81,6 +81,10 @@ export default [
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Charts",
|
||||
pages: ["state-history-charts", "statistics-chart"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
|
||||
@@ -48,6 +48,36 @@ export const createMediaPlayerEntities = () => [
|
||||
group_members: ["media_player.playing", "media_player.stream_playing"],
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.music_all_features",
|
||||
state: "playing",
|
||||
attributes: {
|
||||
friendly_name: "Playing The Music (all features)",
|
||||
media_content_type: "music",
|
||||
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
|
||||
media_artist: "Technohead",
|
||||
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + On + Off +
|
||||
// Play Media + Volume Step + Select Source + Stop + Clear + Play + Shuffle Set +
|
||||
// Sound Mode + Browse Media + Repeat Set + Grouping
|
||||
supported_features: 1048511,
|
||||
entity_picture: "/images/album_cover.jpg",
|
||||
media_duration: 300,
|
||||
media_position: 0,
|
||||
media_position_updated_at: new Date(
|
||||
// 23 seconds in
|
||||
new Date().getTime() - 23000
|
||||
).toISOString(),
|
||||
volume_level: 0.5,
|
||||
is_volume_muted: false,
|
||||
shuffle: false,
|
||||
repeat: "off",
|
||||
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
|
||||
source: "AirPlay",
|
||||
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
|
||||
sound_mode: "Music",
|
||||
group_members: ["media_player.playing", "media_player.stream_playing"],
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.stream_playing",
|
||||
state: "playing",
|
||||
|
||||
@@ -4,7 +4,7 @@ title: "Our story"
|
||||
|
||||
## Open source home automation that puts local control and privacy first
|
||||
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
|
||||
|
||||
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
|
||||
|
||||
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
|
||||
|
||||
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
|
||||
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
|
||||
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
|
||||
@@ -50,6 +50,18 @@ With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
|
||||
row, or from the top match while searching. With one item in the list,
|
||||
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
|
||||
|
||||
## Screen readers
|
||||
|
||||
DOM focus stays in the search field, so screen readers do not hear the list
|
||||
change. While searching, a visually hidden `role="status"` message says how
|
||||
many items match the search and names it, so a new search with the same count
|
||||
is still read out. It is cleared as soon as the list changes and filled in once
|
||||
typing pauses for half a second, so it doesn't talk over each typed character
|
||||
or describe the previous search. When nothing matches, it reads a not
|
||||
found label that takes the search, or says there are no results for it. Section
|
||||
titles, placeholder rows, "Add custom item", the additional items and rows that
|
||||
`isSearchResult` rejects are not counted.
|
||||
|
||||
## Implementation
|
||||
|
||||
Without `sections`, the component filters the items from `getItems` itself as the
|
||||
@@ -79,6 +91,7 @@ means supplying one.
|
||||
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
|
||||
| customValueLabel | String | - | Label for that custom item. |
|
||||
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
|
||||
| isSearchResult | Function | - | Whether a row from `getItems` counts as a match for screen readers. The quick bar leaves out its "Ask Assist" row. |
|
||||
| emptyLabel | String | - | Shown when there are no items at all. |
|
||||
| noSort | Boolean | false | Keeps the order `getItems` returned. |
|
||||
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { IntlMessageFormat } from "intl-messageformat";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-picker-combo-box";
|
||||
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
|
||||
@@ -19,13 +21,26 @@ import type {
|
||||
} from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
// The English strings, plurals included, so searching a demo announces the
|
||||
// same count and search as the real picker.
|
||||
const LOCALIZE_KEYS: Record<string, string> = {
|
||||
"ui.common.search": "Search",
|
||||
"ui.components.combo-box.no_match": "No matching items found",
|
||||
"ui.components.combo-box.no_items": "No items available",
|
||||
"ui.components.combo-box.result_count":
|
||||
"{count} {count, plural,\n one {result}\n other {results}\n} for {term}",
|
||||
"ui.components.combo-box.no_results": "No results for {term}",
|
||||
};
|
||||
|
||||
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
|
||||
const localize: LocalizeFunc = (key, values) => {
|
||||
const message = LOCALIZE_KEYS[key];
|
||||
if (message === undefined) {
|
||||
return key;
|
||||
}
|
||||
return new IntlMessageFormat(message, "en").format<string>(
|
||||
values as Record<string, string | number>
|
||||
) as string;
|
||||
};
|
||||
|
||||
const DEMO_I18N: HomeAssistantInternationalization = {
|
||||
localize,
|
||||
|
||||
@@ -683,8 +683,13 @@ const SCHEMAS: {
|
||||
name: "Unit of measurement",
|
||||
input: {
|
||||
sensor_device_class: {
|
||||
name: "Device class",
|
||||
selector: { device_class: { domain: "sensor" } },
|
||||
name: "Device class of the unit below",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
context: { filter_unit_of_measurement: "unit_linked" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sensor_state_class: {
|
||||
name: "State class",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: History charts
|
||||
subtitle: State history as timelines and line charts.
|
||||
---
|
||||
|
||||
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/state-history-charts";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
HistoryResult,
|
||||
LineChartState,
|
||||
TimelineState,
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
|
||||
const END = new Date();
|
||||
END.setMinutes(0, 0, 0);
|
||||
const START = new Date(END.getTime() - HOURS * HOUR);
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "sensor.outdoor_temperature",
|
||||
state: "12.4",
|
||||
attributes: {
|
||||
friendly_name: "Outdoor temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Living room light" },
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Front door", device_class: "door" },
|
||||
},
|
||||
];
|
||||
|
||||
const temperatureStates = (): LineChartState[] =>
|
||||
Array.from({ length: HOURS * 2 }, (_, index) => {
|
||||
const time = START.getTime() + (index * HOUR) / 2;
|
||||
const hourOfDay = new Date(time).getHours();
|
||||
const value =
|
||||
10 +
|
||||
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
|
||||
0.5 * Math.sin(time / HOUR);
|
||||
return {
|
||||
state: value.toFixed(1),
|
||||
last_changed: time,
|
||||
};
|
||||
});
|
||||
|
||||
const toggleStates = (
|
||||
onHours: [number, number][],
|
||||
onLabel: string,
|
||||
offLabel: string
|
||||
): TimelineState[] => {
|
||||
const on = { state: "on", state_localize: onLabel };
|
||||
const off = { state: "off", state_localize: offLabel };
|
||||
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
|
||||
onHours.forEach(([from, to]) => {
|
||||
states.push(
|
||||
{ ...on, last_changed: START.getTime() + from * HOUR },
|
||||
{ ...off, last_changed: START.getTime() + to * HOUR }
|
||||
);
|
||||
});
|
||||
return states;
|
||||
};
|
||||
|
||||
const HISTORY: HistoryResult = {
|
||||
line: [
|
||||
{
|
||||
unit: "°C",
|
||||
device_class: "temperature",
|
||||
identifier: "temperature",
|
||||
data: [
|
||||
{
|
||||
domain: "sensor",
|
||||
name: "Outdoor temperature",
|
||||
entity_id: "sensor.outdoor_temperature",
|
||||
states: temperatureStates(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
name: "Living room light",
|
||||
entity_id: "light.living_room",
|
||||
data: toggleStates(
|
||||
[
|
||||
[6, 8],
|
||||
[18, 23],
|
||||
[30, 32],
|
||||
[42, 47],
|
||||
],
|
||||
"On",
|
||||
"Off"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Front door",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
data: toggleStates(
|
||||
[
|
||||
[7.5, 7.6],
|
||||
[17, 17.2],
|
||||
[31.5, 31.6],
|
||||
[41, 41.1],
|
||||
],
|
||||
"Open",
|
||||
"Closed"
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const LIGHT_ONLY: HistoryResult = {
|
||||
line: [],
|
||||
timeline: [HISTORY.timeline[0]],
|
||||
};
|
||||
|
||||
interface Example {
|
||||
title: string;
|
||||
description: string;
|
||||
history: HistoryResult;
|
||||
showNames?: boolean;
|
||||
insideLabels?: boolean;
|
||||
syncCharts?: boolean;
|
||||
}
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Names in a column",
|
||||
description:
|
||||
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
|
||||
history: HISTORY,
|
||||
},
|
||||
{
|
||||
title: "Names inside the timeline, synced zoom",
|
||||
description:
|
||||
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
|
||||
history: HISTORY,
|
||||
insideLabels: true,
|
||||
syncCharts: true,
|
||||
},
|
||||
{
|
||||
title: "Single entity without names",
|
||||
description:
|
||||
"Like the more-info dialog, where the entity is already known.",
|
||||
history: LIGHT_ONLY,
|
||||
showNames: false,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-state-history-charts")
|
||||
export class DemoStateHistoryCharts extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<state-history-charts
|
||||
.historyData=${example.history}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.showNames=${example.showNames ?? true}
|
||||
.insideLabels=${example.insideLabels ?? false}
|
||||
.syncCharts=${example.syncCharts ?? false}
|
||||
.clickForMoreInfo=${false}
|
||||
></state-history-charts>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-state-history-charts": DemoStateHistoryCharts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Statistics chart
|
||||
subtitle: Long-term statistics as a bar or line chart.
|
||||
---
|
||||
|
||||
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
StatisticsMetaData,
|
||||
StatisticType,
|
||||
StatisticValue,
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
|
||||
const END = new Date();
|
||||
END.setMinutes(0, 0, 0);
|
||||
const START = new Date(END.getTime() - HOURS * HOUR);
|
||||
|
||||
const round = (value: number, digits: number) =>
|
||||
Math.round(value * 10 ** digits) / 10 ** digits;
|
||||
|
||||
const hourlyValues = (
|
||||
compute: (start: number) => Partial<StatisticValue>
|
||||
): StatisticValue[] =>
|
||||
Array.from({ length: HOURS }, (_, hour) => {
|
||||
const start = START.getTime() + hour * HOUR;
|
||||
return { start, end: start + HOUR, ...compute(start) };
|
||||
});
|
||||
|
||||
const energyValues = (dayUse: number, eveningUse: number) => {
|
||||
let sum = 0;
|
||||
return hourlyValues((start) => {
|
||||
const hourOfDay = new Date(start).getHours();
|
||||
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
|
||||
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
|
||||
sum += change;
|
||||
return { change, sum: round(sum, 2) };
|
||||
});
|
||||
};
|
||||
|
||||
const temperatureValues = (average: number, swing: number) =>
|
||||
hourlyValues((start) => {
|
||||
const hourOfDay = new Date(start).getHours();
|
||||
const mean =
|
||||
average +
|
||||
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
|
||||
0.3 * Math.sin(start / HOUR);
|
||||
return {
|
||||
mean: round(mean, 1),
|
||||
min: round(mean - 0.4, 1),
|
||||
max: round(mean + 0.4, 1),
|
||||
};
|
||||
});
|
||||
|
||||
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
|
||||
const LIGHTING_ID = "sensor.lighting_energy";
|
||||
const LIVING_ROOM_ID = "sensor.living_room_temperature";
|
||||
const BEDROOM_ID = "sensor.bedroom_temperature";
|
||||
|
||||
const HEAT_PUMP = energyValues(0.4, 1.2);
|
||||
const LIGHTING = energyValues(0.05, 0.3);
|
||||
const LIVING_ROOM = temperatureValues(21, 1.5);
|
||||
const BEDROOM = temperatureValues(18.5, 1);
|
||||
|
||||
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
|
||||
statistic_id: id,
|
||||
statistics_unit_of_measurement: "kWh",
|
||||
source: "recorder",
|
||||
name,
|
||||
has_sum: true,
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
unit_class: "energy",
|
||||
});
|
||||
|
||||
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
|
||||
statistic_id: id,
|
||||
statistics_unit_of_measurement: "°C",
|
||||
source: "recorder",
|
||||
name,
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
});
|
||||
|
||||
const METADATA: Record<string, StatisticsMetaData> = {
|
||||
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
|
||||
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
|
||||
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
|
||||
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
|
||||
};
|
||||
|
||||
const NAMES = Object.fromEntries(
|
||||
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
|
||||
);
|
||||
|
||||
interface Example {
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "line-stack" | "bar" | "bar-stack";
|
||||
unit: string;
|
||||
hideLegend?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
description:
|
||||
"The change of a sum for each hour, like an energy statistics graph card.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
},
|
||||
{
|
||||
title: "Stacked bars",
|
||||
description: "Several sums stacked, to compare what each one adds.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar-stack",
|
||||
unit: "kWh",
|
||||
},
|
||||
{
|
||||
title: "Mean, min and max",
|
||||
description: "A measurement with its hourly mean, min and max.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
},
|
||||
{
|
||||
title: "Several statistics",
|
||||
description: "The mean of several measurements with the same unit.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
|
||||
statTypes: ["mean"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
},
|
||||
{
|
||||
title: "Without legend",
|
||||
description:
|
||||
"A single statistic with a fixed height, like in the more-info dialog.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
hideLegend: true,
|
||||
height: "240px",
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-statistics-chart")
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.hideLegend=${example.hideLegend ?? false}
|
||||
.height=${example.height}
|
||||
.clickForMoreInfo=${false}
|
||||
></statistics-chart>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-statistics-chart": DemoStatisticsChart;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ const CONFIGS = [
|
||||
entity: "media_player.music_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Music (all features)",
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.music_all_features",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Stream",
|
||||
config: {
|
||||
|
||||
@@ -201,6 +201,50 @@ const ENTITIES = [
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.on_off_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Bathroom fan",
|
||||
device_class: "fan",
|
||||
percentage: 100,
|
||||
percentage_step: 100,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.six_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 100 / 6,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.ten_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Living room fan",
|
||||
device_class: "fan",
|
||||
percentage: 70,
|
||||
percentage_step: 10,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.eleven_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Office fan",
|
||||
device_class: "fan",
|
||||
percentage: 45,
|
||||
percentage_step: 100 / 11,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
@@ -410,6 +454,55 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (5 speeds, slider style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.on_off_fan",
|
||||
features: [{ type: "fan-speed", style: "slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (6 speeds, slider by default)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (6 speeds, buttons style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (10 speeds, buttons style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.ten_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Fan speed feature (11 speeds, buttons style falls back to slider)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.eleven_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (half width)",
|
||||
config: {
|
||||
@@ -430,6 +523,27 @@ const CONFIGS = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.ten_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: {
|
||||
|
||||
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["google_assistant", "Google Home", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
|
||||
+35
-34
@@ -13,7 +13,7 @@
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
|
||||
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.8",
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -54,17 +54,17 @@
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.2",
|
||||
"@formatjs/intl-displaynames": "7.3.15",
|
||||
"@formatjs/intl-durationformat": "0.11.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.12",
|
||||
"@formatjs/intl-listformat": "8.3.15",
|
||||
"@formatjs/intl-locale": "5.3.12",
|
||||
"@formatjs/intl-numberformat": "9.4.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.16",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.16",
|
||||
"@formatjs/intl-datetimeformat": "8.0.2",
|
||||
"@formatjs/intl-displaynames": "8.0.1",
|
||||
"@formatjs/intl-durationformat": "0.12.3",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.0",
|
||||
"@formatjs/intl-listformat": "9.0.1",
|
||||
"@formatjs/intl-locale": "5.4.2",
|
||||
"@formatjs/intl-numberformat": "10.0.2",
|
||||
"@formatjs/intl-pluralrules": "7.0.2",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.1",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.2",
|
||||
"@lezer/common": "1.5.4",
|
||||
"@lezer/common": "1.5.5",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -78,11 +78,11 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "7.0.1",
|
||||
"@tsparticles/engine": "4.5.1",
|
||||
"@tsparticles/preset-links": "4.5.1",
|
||||
"@versatiles/style": "8.1.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.3",
|
||||
"cally": "0.9.2",
|
||||
@@ -98,10 +98,10 @@
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fullcalendar": "7.1.4",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.8.0",
|
||||
"hls.js": "1.7.4",
|
||||
"home-assistant-js-websocket": "9.8.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.4",
|
||||
"js-yaml": "5.5.0",
|
||||
@@ -110,7 +110,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"marked": "18.1.1",
|
||||
"marked": "18.1.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +123,7 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"temporal-polyfill": "1.0.7",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -137,24 +137,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.9",
|
||||
"@babel/core": "8.1.0",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@lokalise/node-api": "17.0.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.9",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -170,14 +171,14 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.4",
|
||||
"browserslist": "4.29.5",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.51.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint": "10.14.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -188,8 +189,8 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.13.0",
|
||||
"glob": "14.0.3",
|
||||
"globals": "17.14.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -214,16 +215,16 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.5",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]5.0",
|
||||
"packageManager": "[email protected]7.0",
|
||||
"volta": {
|
||||
"node": "24.22.1"
|
||||
"node": "24.22.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# @home-assistant/components
|
||||
|
||||
Home Assistant Components (`hac-*`): framework-agnostic web components built
|
||||
with [Lit](https://lit.dev).
|
||||
|
||||
> Work in progress, not published yet.
|
||||
|
||||
## Registration
|
||||
|
||||
Every element is imported on its own, in one of two ways:
|
||||
|
||||
| Import | Registers in | For |
|
||||
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
|
||||
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
|
||||
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
|
||||
|
||||
`<element>` is the tag name without the `hac-` prefix, like `card` for
|
||||
`hac-card`.
|
||||
|
||||
Both use the copy bundled with your code. A scoped copy is isolated from the
|
||||
one Home Assistant registers globally, so a custom card keeps the version it
|
||||
was built with.
|
||||
|
||||
Global registration skips tag names that are already registered, so the first
|
||||
registration wins and nothing throws.
|
||||
|
||||
### Global
|
||||
|
||||
```ts
|
||||
import "@home-assistant/components/card";
|
||||
```
|
||||
|
||||
### Scoped
|
||||
|
||||
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
|
||||
`createScopedElementRegistry` for plain custom elements. Forgetting the
|
||||
elements is a TypeScript error, and a runtime error in plain JavaScript.
|
||||
|
||||
Lit:
|
||||
|
||||
```ts
|
||||
import { LitElement, html } from "lit";
|
||||
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
|
||||
render() {
|
||||
return html`<hac-card>…</hac-card>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plain custom elements:
|
||||
|
||||
```js
|
||||
import {
|
||||
attachScopedShadow,
|
||||
createScopedElement,
|
||||
createScopedElementRegistry,
|
||||
} from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
// Create the registry once, outside the class: all instances of the card share it
|
||||
const registry = createScopedElementRegistry([hacCard]);
|
||||
|
||||
class MyCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = attachScopedShadow(this, { mode: "open" }, registry);
|
||||
root.innerHTML = "<hac-card>…</hac-card>";
|
||||
// or: root.append(createScopedElement(root, "hac-card"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
|
||||
registry. `document.createElement` uses the global one, so avoid it for
|
||||
scoped elements.
|
||||
|
||||
Scoped registries need native support (Chrome 146+, Safari 26) or
|
||||
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
|
||||
Without either, elements are registered globally instead, and an already
|
||||
registered tag name keeps its existing class.
|
||||
|
||||
Only elements rendered inside the shadow root use the scoped registry. Light
|
||||
DOM content, such as slotted elements, uses the registry of the tree it is
|
||||
written in.
|
||||
|
||||
`lit` is a peer dependency.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
|
||||
`document.querySelector("hac-card")` and templates are typed once the element
|
||||
is imported. For the class itself, import its type from the `scoped` entry
|
||||
point; `import type` loads nothing at runtime:
|
||||
|
||||
```ts
|
||||
import type { HacCard } from "@home-assistant/components/card/scoped";
|
||||
```
|
||||
|
||||
The package has no root entry point: each element and the scoped helpers
|
||||
(`@home-assistant/components/scoped`) have their own.
|
||||
|
||||
## Theming
|
||||
|
||||
Each component exposes its own `--hac-*` custom properties, documented in its
|
||||
source, which take precedence. Next come the theme variables of the existing
|
||||
Home Assistant element it replaces, so current themes keep working: `hac-card`
|
||||
reads the variables of `ha-card` (`--ha-card-background`,
|
||||
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
|
||||
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
|
||||
their light values, so the components also render outside Home Assistant.
|
||||
|
||||
The legacy theme variables are a temporary bridge for this experiment, not the
|
||||
target design. Home Assistant always defines some of them, like
|
||||
`--card-background-color`, so inside Home Assistant they currently win over
|
||||
the semantic tokens. Once themes move to the semantic tokens, the legacy
|
||||
variables will be removed from the fallbacks.
|
||||
|
||||
## Structure
|
||||
|
||||
Each element has its own directory, named like its import path:
|
||||
|
||||
```
|
||||
src/components/card/ <hac-card> -> @home-assistant/components/card
|
||||
hac-card.ts class, does not register itself
|
||||
info.ts tag name and version
|
||||
global.ts registers globally
|
||||
scoped.ts element for a scoped registry -> …/card/scoped
|
||||
```
|
||||
|
||||
Shared code lives in `src/utils/`: `registration/` for defining elements
|
||||
(global and scoped) and `element/` for helpers that components use
|
||||
themselves, like slot checks.
|
||||
|
||||
Every element has its own version in `info.ts`.
|
||||
|
||||
Entry points are mapped with subpath patterns in `package.json`, so a new
|
||||
element directory needs no `package.json` change. Only `global.ts` files have
|
||||
side effects, which `sideEffects` relies on for tree shaking.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @home-assistant/components build # ESM, type declarations and custom-elements.json to dist/
|
||||
pnpm --filter @home-assistant/components dev # rebuild on change
|
||||
```
|
||||
|
||||
Inside this repository the app uses the sources directly through
|
||||
the `ha-source` export condition, so no build is needed during development.
|
||||
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
|
||||
source file becomes its own ES module. The build then generates a
|
||||
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
|
||||
the JSDoc of each element, so document new slots, parts, CSS properties and
|
||||
events there.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
|
||||
// editors and documentation tools. Only hac-*.ts files declare elements; the
|
||||
// entry points just re-export them.
|
||||
|
||||
const forEachDeclaration = (customElementsManifest, callback) => {
|
||||
for (const module of customElementsManifest.modules) {
|
||||
for (const declaration of module.declarations ?? []) {
|
||||
callback(declaration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The analyzer leaves the readme empty. Paths in the manifest are relative to
|
||||
// the package root, like the module paths.
|
||||
const packageReadme = () => ({
|
||||
name: "package-readme",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
customElementsManifest.readme = "README.md";
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are documented with @summary, which the analyzer only puts into
|
||||
// `summary`. Most tools show `description`, so fall back to the summary.
|
||||
const summaryAsDescription = () => ({
|
||||
name: "summary-as-description",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
if (!declaration.description && declaration.summary) {
|
||||
declaration.description = declaration.summary;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Private and protected members are internal, so keep them out of the
|
||||
// documented API.
|
||||
const publicApiOnly = () => ({
|
||||
name: "public-api-only",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
declaration.members = declaration.members?.filter(
|
||||
(member) => !member.privacy || member.privacy === "public"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const distPaths = () => {
|
||||
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
|
||||
const rewrite = (node) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(rewrite);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if ((key === "path" || key === "module") && typeof value === "string") {
|
||||
node[key] = toDist(value);
|
||||
} else {
|
||||
rewrite(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
name: "dist-paths",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
rewrite(customElementsManifest.modules);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
globs: ["src/components/**/hac-*.ts"],
|
||||
outdir: "dist",
|
||||
litelement: true,
|
||||
plugins: [
|
||||
packageReadme(),
|
||||
summaryAsDescription(),
|
||||
publicApiOnly(),
|
||||
distPaths(),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@home-assistant/components",
|
||||
"version": "0.0.0",
|
||||
"description": "Home Assistant web components (hac-* elements) built with Lit",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/home-assistant/frontend.git",
|
||||
"directory": "packages/components"
|
||||
},
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/components/**/global.js",
|
||||
"./src/components/**/global.ts"
|
||||
],
|
||||
"customElements": "dist/custom-elements.json",
|
||||
"exports": {
|
||||
"./scoped": {
|
||||
"ha-source": "./src/scoped.ts",
|
||||
"types": "./dist/scoped.d.ts",
|
||||
"import": "./dist/scoped.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./*/scoped": {
|
||||
"ha-source": "./src/components/*/scoped.ts",
|
||||
"types": "./dist/components/*/scoped.d.ts",
|
||||
"import": "./dist/components/*/scoped.js"
|
||||
},
|
||||
"./*/info": {
|
||||
"ha-source": "./src/components/*/info.ts",
|
||||
"types": "./dist/components/*/info.d.ts",
|
||||
"import": "./dist/components/*/info.js"
|
||||
},
|
||||
"./*": {
|
||||
"ha-source": "./src/components/*/global.ts",
|
||||
"types": "./dist/components/*/global.d.ts",
|
||||
"import": "./dist/components/*/global.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rslib build && cem analyze",
|
||||
"dev": "rslib build --watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "0.11.0",
|
||||
"@rslib/core": "1.0.5",
|
||||
"lit": "3.3.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
// Bundleless build: every source file becomes its own ESM file, so consumers'
|
||||
// bundlers only include the entry points they import. Only global.ts files
|
||||
// register elements; everything else is free of side effects.
|
||||
export default defineConfig({
|
||||
source: {
|
||||
entry: { index: ["./src/**/*.ts"] },
|
||||
decorators: { version: "legacy" },
|
||||
tsconfigPath: "./tsconfig.json",
|
||||
},
|
||||
tools: {
|
||||
swc: { jsc: { transform: { decoratorMetadata: false } } },
|
||||
},
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
bundle: false,
|
||||
syntax: "es2021",
|
||||
dts: true,
|
||||
output: { target: "web", distPath: { root: "./dist" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { define } from "../../utils/registration/define";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-card> in the global registry, as used by Home Assistant
|
||||
define(TAG_NAME, HacCard);
|
||||
|
||||
export { HacCard };
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../../utils/element/slot";
|
||||
|
||||
/**
|
||||
* @element hac-card
|
||||
*
|
||||
* @summary
|
||||
* Card that groups related content, with an optional header and footer.
|
||||
*
|
||||
* @slot header - Header of the card. Its spacing replaces the top padding of
|
||||
* the content.
|
||||
* @slot - Content of the card. Its padding is omitted when empty.
|
||||
* @slot footer - Footer of the card. Hidden when empty.
|
||||
*
|
||||
* @csspart header - Wrapper of the header slot.
|
||||
* @csspart content - Wrapper of the default slot.
|
||||
* @csspart footer - Wrapper of the footer slot.
|
||||
*
|
||||
* @cssprop --hac-card-background - Background of the card.
|
||||
* @cssprop --hac-card-color - Text color of the card.
|
||||
* @cssprop --hac-card-border-color - Border color of the card and the footer divider.
|
||||
* @cssprop --hac-card-border-width - Border width of the card.
|
||||
* @cssprop --hac-card-border-radius - Border radius of the card.
|
||||
* @cssprop --hac-card-box-shadow - Box shadow of the card.
|
||||
* @cssprop --hac-card-content-padding - Padding of the content.
|
||||
* @cssprop --hac-card-footer-padding - Padding of the footer.
|
||||
*/
|
||||
export class HacCard extends LitElement {
|
||||
/**
|
||||
* Visual style of the card. `outlined` draws a border without a shadow.
|
||||
* `raised` hides the border and lifts the card with a shadow instead.
|
||||
*/
|
||||
@property({ reflect: true }) public appearance: "outlined" | "raised" =
|
||||
"outlined";
|
||||
|
||||
@state() private _hasHeader = false;
|
||||
|
||||
@state() private _hasFooter = false;
|
||||
|
||||
@state() private _hasContent = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header" part="header" ?hidden=${!this._hasHeader}>
|
||||
<slot name="header" @slotchange=${this._headerChanged}></slot>
|
||||
</div>
|
||||
<div
|
||||
class="content ${this._hasHeader ? "below-header" : ""}"
|
||||
part="content"
|
||||
?hidden=${!this._hasContent}
|
||||
>
|
||||
<slot @slotchange=${this._contentChanged}></slot>
|
||||
</div>
|
||||
<div class="footer" part="footer" ?hidden=${!this._hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._footerChanged}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _headerChanged(ev: Event) {
|
||||
this._hasHeader = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _contentChanged(ev: Event) {
|
||||
this._hasContent = hasAssignedContent(ev);
|
||||
}
|
||||
|
||||
private _footerChanged(ev: Event) {
|
||||
this._hasFooter = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallback order: --hac-card-*, then the theme variables of ha-card
|
||||
* (--ha-card-*, --card-background-color, --divider-color,
|
||||
* --primary-text-color) so existing themes keep working, then the semantic
|
||||
* tokens and their light values for use outside Home Assistant.
|
||||
* The ha-card variables are temporary, see "Theming" in the README.
|
||||
*/
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(
|
||||
--hac-card-background,
|
||||
var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default, #fff))
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
color: var(
|
||||
--hac-card-color,
|
||||
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
|
||||
);
|
||||
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
|
||||
solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(
|
||||
--ha-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
)
|
||||
);
|
||||
border-radius: var(
|
||||
--hac-card-border-radius,
|
||||
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
|
||||
);
|
||||
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
|
||||
}
|
||||
|
||||
:host([appearance="raised"]) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(
|
||||
--hac-card-box-shadow,
|
||||
var(
|
||||
--ha-card-box-shadow,
|
||||
var(
|
||||
--ha-box-shadow-s,
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.08),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.12)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
|
||||
}
|
||||
|
||||
/* The header already provides the spacing above the content */
|
||||
.content.below-header {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
|
||||
border-block-start: 1px solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
);
|
||||
}
|
||||
|
||||
.header[hidden],
|
||||
.content[hidden],
|
||||
.footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-card": HacCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-card";
|
||||
|
||||
/** Version of <hac-card>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacCard };
|
||||
|
||||
/**
|
||||
* The bundled <hac-card> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacCard])` or
|
||||
* `createScopedElementRegistry([hacCard])`.
|
||||
*/
|
||||
export const hacCard: ElementDefinition<typeof HacCard> = {
|
||||
tagName: TAG_NAME,
|
||||
element: HacCard,
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { define } from "./utils/registration/define";
|
||||
import type { ElementDefinition } from "./utils/registration/definition";
|
||||
import {
|
||||
createScopedRegistry,
|
||||
scopedCreationScope,
|
||||
scopedRegistrySupport,
|
||||
scopedShadowRootInit,
|
||||
} from "./utils/registration/scoped-registry";
|
||||
|
||||
export type { ElementDefinition } from "./utils/registration/definition";
|
||||
|
||||
/**
|
||||
* Creates a registry with the given elements, for `attachScopedShadow`:
|
||||
* definitions from `scoped` entry points, e.g. `[hacCard]`.
|
||||
* Create it once, outside the class, so all instances of a card share it.
|
||||
*
|
||||
* Without scoped registry support (native or polyfill), the elements are
|
||||
* registered in the global registry instead, unless their tag name is already
|
||||
* taken there, and the global registry is returned.
|
||||
*/
|
||||
export const createScopedElementRegistry = (
|
||||
elements: readonly ElementDefinition[]
|
||||
): CustomElementRegistry => {
|
||||
// Checked as unknown: Array.isArray would narrow the list to any[]
|
||||
if (!Array.isArray(elements as unknown)) {
|
||||
throw new TypeError(
|
||||
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
|
||||
);
|
||||
}
|
||||
const registry = createScopedRegistry() ?? customElements;
|
||||
for (const { tagName, element } of elements) {
|
||||
define(tagName, element, registry);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
const shadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
registry === customElements ? init : scopedShadowRootInit(init, registry);
|
||||
|
||||
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
|
||||
|
||||
/**
|
||||
* Attaches a shadow root that uses `registry` for custom elements in it.
|
||||
*
|
||||
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
|
||||
* Create single elements with `createScopedElement`; `document.createElement`
|
||||
* would use the global registry.
|
||||
*
|
||||
* Light DOM content, like slotted elements, keeps using the registry of the
|
||||
* tree it is written in.
|
||||
*/
|
||||
export const attachScopedShadow = (
|
||||
host: HTMLElement,
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRoot => {
|
||||
const root = host.attachShadow(shadowRootInit(init, registry));
|
||||
registries.set(root, registry);
|
||||
return root;
|
||||
};
|
||||
|
||||
/** Creates an element from the registry of a shadow root. */
|
||||
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
|
||||
root: ShadowRoot,
|
||||
tagName: K
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const registry = registries.get(root);
|
||||
if (!registry || registry === customElements) {
|
||||
return document.createElement(tagName);
|
||||
}
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds createElement to shadow roots
|
||||
return (
|
||||
root as unknown as {
|
||||
createElement(tagName: K): HTMLElementTagNameMap[K];
|
||||
}
|
||||
).createElement(tagName);
|
||||
}
|
||||
return (
|
||||
document.createElement as (
|
||||
tagName: K,
|
||||
options: { customElementRegistry: CustomElementRegistry }
|
||||
) => HTMLElementTagNameMap[K]
|
||||
)(tagName, { customElementRegistry: registry });
|
||||
};
|
||||
|
||||
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
|
||||
|
||||
/**
|
||||
* Renders a Lit element into a shadow root with its own custom element
|
||||
* registry, containing `elements`:
|
||||
*
|
||||
* ```ts
|
||||
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
|
||||
* ```
|
||||
*
|
||||
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
|
||||
*/
|
||||
export const ScopedElementsMixin = <T extends LitElementConstructor>(
|
||||
superClass: T,
|
||||
elements: readonly ElementDefinition[]
|
||||
) => {
|
||||
// Created on first use, so defining the class has no side effects
|
||||
let registry: CustomElementRegistry | undefined;
|
||||
const getRegistry = () => {
|
||||
registry ??= createScopedElementRegistry(elements);
|
||||
return registry;
|
||||
};
|
||||
|
||||
abstract class ScopedElementsHost extends superClass {
|
||||
public attachShadow(init: ShadowRootInit): ShadowRoot {
|
||||
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
|
||||
registries.set(root, getRegistry());
|
||||
return root;
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
const scoped = getRegistry();
|
||||
if (scoped !== customElements && root instanceof ShadowRoot) {
|
||||
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
// Typed as the base class: the overrides add no public API
|
||||
return ScopedElementsHost as unknown as T;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Whether the slot of a `slotchange` event has elements assigned. */
|
||||
export const hasAssignedElements = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
|
||||
/**
|
||||
* Whether the slot of a `slotchange` event has content assigned: elements or
|
||||
* text. Whitespace between tags is not content.
|
||||
*/
|
||||
export const hasAssignedContent = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement)
|
||||
.assignedNodes()
|
||||
.some(
|
||||
(node) =>
|
||||
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Registers a custom element unless the tag name is already taken in the
|
||||
* registry.
|
||||
*
|
||||
* Home Assistant and custom cards can each ship a copy of this library, so
|
||||
* registering the same tag name twice must not throw. The first registration
|
||||
* wins.
|
||||
*/
|
||||
export const define = (
|
||||
tagName: string,
|
||||
elementClass: CustomElementConstructor,
|
||||
registry: CustomElementRegistry = customElements
|
||||
): void => {
|
||||
if (!registry.get(tagName)) {
|
||||
registry.define(tagName, elementClass);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* An element to register: its tag name and its class. The `scoped` entry
|
||||
* point of an element exports one, e.g. `hacCard`.
|
||||
*/
|
||||
export interface ElementDefinition<
|
||||
E extends CustomElementConstructor = CustomElementConstructor,
|
||||
> {
|
||||
readonly tagName: string;
|
||||
readonly element: E;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Scoped custom element registries come in two flavors: the standard API
|
||||
* (Chrome 146+, Safari 26) and the older API of
|
||||
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
|
||||
* and which replaces the native implementation when present.
|
||||
*/
|
||||
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
|
||||
|
||||
let support: ScopedRegistrySupport | undefined;
|
||||
|
||||
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
|
||||
if (support) {
|
||||
return support;
|
||||
}
|
||||
if ("CustomElementRegistryPolyfill" in globalThis) {
|
||||
support = "polyfill";
|
||||
} else {
|
||||
try {
|
||||
new CustomElementRegistry();
|
||||
support = "native";
|
||||
} catch (_err) {
|
||||
support = "none";
|
||||
}
|
||||
}
|
||||
return support;
|
||||
};
|
||||
|
||||
/** A new scoped registry, or `undefined` if the browser has no support. */
|
||||
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
|
||||
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
|
||||
|
||||
/** Shadow root options that make the shadow root use `registry`. */
|
||||
export const scopedShadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
scopedRegistrySupport() === "polyfill"
|
||||
? ({ ...init, registry } as ShadowRootInit)
|
||||
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
|
||||
|
||||
interface CreationScope {
|
||||
importNode(node: Node, deep?: boolean): Node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object Lit should create template content with, so custom elements in
|
||||
* it are created from the scoped registry (Lit's `creationScope`).
|
||||
*/
|
||||
export const scopedCreationScope = (
|
||||
root: ShadowRoot,
|
||||
registry: CustomElementRegistry
|
||||
): CreationScope => {
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds importNode to shadow roots
|
||||
return root as unknown as CreationScope;
|
||||
}
|
||||
// Standard API: the registry is passed to importNode
|
||||
return {
|
||||
importNode: (node, deep) =>
|
||||
(
|
||||
document.importNode as (
|
||||
node: Node,
|
||||
options: { selfOnly: boolean; customElementRegistry: unknown }
|
||||
) => Node
|
||||
)(node, { selfOnly: !deep, customElementRegistry: registry }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { define } from "../src/utils/registration/define";
|
||||
|
||||
// Home Assistant and custom cards can each ship a copy of the library, so a
|
||||
// second registration of the same tag must be ignored instead of throwing.
|
||||
describe("define", () => {
|
||||
it("registers an element that is not registered yet", () => {
|
||||
class First extends HTMLElement {}
|
||||
define("hac-test-first", First);
|
||||
expect(customElements.get("hac-test-first")).toBe(First);
|
||||
});
|
||||
|
||||
it("keeps the first registration of a tag name", () => {
|
||||
class Original extends HTMLElement {}
|
||||
class Duplicate extends HTMLElement {}
|
||||
define("hac-test-duplicate", Original);
|
||||
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
|
||||
expect(customElements.get("hac-test-duplicate")).toBe(Original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../src/utils/element/slot";
|
||||
|
||||
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
|
||||
// Whitespace between tags must not count as content.
|
||||
const slotEvent = (light: string, name?: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = light;
|
||||
const slot = document.createElement("slot");
|
||||
if (name) {
|
||||
slot.name = name;
|
||||
}
|
||||
host.attachShadow({ mode: "open" }).append(slot);
|
||||
return { target: slot } as unknown as Event;
|
||||
};
|
||||
|
||||
describe("hasAssignedContent", () => {
|
||||
it("ignores whitespace", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts text", () => {
|
||||
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
|
||||
});
|
||||
|
||||
it("counts elements", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAssignedElements", () => {
|
||||
it("only counts elements assigned to the slot", () => {
|
||||
const light = '<span slot="icon"></span> text';
|
||||
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
|
||||
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
|
||||
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
|
||||
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
|
||||
--- a/modular/sortable.core.esm.js
|
||||
+++ b/modular/sortable.core.esm.js
|
||||
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
}
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
|
||||
capture();
|
||||
@@ -24,7 +24,7 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
}
|
||||
parentEl = el; // actualization
|
||||
|
||||
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
targetRect = getRect(target);
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
|
||||
capture();
|
||||
@@ -38,7 +38,16 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
parentEl = el; // actualization
|
||||
|
||||
changed();
|
||||
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
pastFirstInvertThresh = false;
|
||||
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
|
||||
}
|
||||
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
|
||||
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
|
||||
var sibling;
|
||||
if (direction !== 0) {
|
||||
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
|
||||
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
_silent = true;
|
||||
setTimeout(_unsilent, 30);
|
||||
capture();
|
||||
@@ -58,3 +67,53 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
}
|
||||
|
||||
// Undo chrome's scroll adjustment (has no effect on other browsers)
|
||||
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
|
||||
function _unsilent() {
|
||||
_silent = false;
|
||||
}
|
||||
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
|
||||
+// checks below keep assuming that the list runs from left to right.
|
||||
+function _isRtl(el, vertical) {
|
||||
+ return !vertical && css(el, 'direction') === 'rtl';
|
||||
+}
|
||||
+function _mirrorX(rect) {
|
||||
+ return {
|
||||
+ left: -rect.right,
|
||||
+ right: -rect.left,
|
||||
+ top: rect.top,
|
||||
+ bottom: rect.bottom,
|
||||
+ width: rect.width,
|
||||
+ height: rect.height
|
||||
+ };
|
||||
+}
|
||||
function _ghostIsFirst(evt, vertical, sortable) {
|
||||
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
|
||||
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
|
||||
+ if (_isRtl(sortable.el, vertical)) {
|
||||
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
|
||||
+ firstElRect = _mirrorX(firstElRect);
|
||||
+ childContainingRect = _mirrorX(childContainingRect);
|
||||
+ }
|
||||
var spacer = 10;
|
||||
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
|
||||
}
|
||||
function _ghostIsLast(evt, vertical, sortable) {
|
||||
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
|
||||
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
|
||||
+ if (_isRtl(sortable.el, vertical)) {
|
||||
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
|
||||
+ lastElRect = _mirrorX(lastElRect);
|
||||
+ childContainingRect = _mirrorX(childContainingRect);
|
||||
+ }
|
||||
var spacer = 10;
|
||||
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
|
||||
}
|
||||
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
|
||||
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
|
||||
+ if (rtl) {
|
||||
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
|
||||
+ targetRect = _mirrorX(targetRect);
|
||||
+ }
|
||||
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
|
||||
targetLength = vertical ? targetRect.height : targetRect.width,
|
||||
targetS1 = vertical ? targetRect.top : targetRect.left,
|
||||
Generated
+1709
-1017
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "."
|
||||
- "packages/*"
|
||||
|
||||
savePrefix: ""
|
||||
autoInstallPeers: false
|
||||
|
||||
@@ -19,6 +23,7 @@ trustPolicyExclude:
|
||||
allowBuilds:
|
||||
"@tsparticles/engine": false
|
||||
core-js: false
|
||||
rs-module-lexer: false
|
||||
unrs-resolver: false
|
||||
|
||||
overrides:
|
||||
@@ -26,7 +31,7 @@ overrides:
|
||||
lit-html: 3.3.3
|
||||
clean-css: 5.3.3
|
||||
"@lit/reactive-element": 2.1.2
|
||||
tslib: 2.8.1
|
||||
tslib: 2.9.1
|
||||
|
||||
patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="28" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="48" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="68" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 584 B |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20261028.5"
|
||||
version = "20261028.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
+18
-1
@@ -13,6 +13,9 @@
|
||||
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
"constraints": {
|
||||
"ghActionsLock": "v0.4.1"
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"description": ["Run after patch releases but before next beta"],
|
||||
"enabled": true,
|
||||
@@ -59,12 +62,26 @@
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.github/workflows/actions-lock\\.yaml$/",
|
||||
"/^renovate\\.json$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
|
||||
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
|
||||
],
|
||||
"depNameTemplate": "github/gh-actions-lock",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"description": "Keep Playwright CI container image up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-icon-next";
|
||||
import "../components/ha-list";
|
||||
import "../components/ha-list-item";
|
||||
import "../components/item/ha-list-item-button";
|
||||
import "../components/list/ha-list-base";
|
||||
import type { AuthProvider } from "../data/auth";
|
||||
|
||||
declare global {
|
||||
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
|
||||
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
|
||||
>
|
||||
</h3>
|
||||
<ha-list>
|
||||
<ha-list-base>
|
||||
${this.authProviders.map(
|
||||
(provider) => html`
|
||||
<ha-list-item
|
||||
hasMeta
|
||||
role="button"
|
||||
<ha-list-item-button
|
||||
.auth_provider=${provider}
|
||||
@click=${this._handlePick}
|
||||
>
|
||||
${provider.name}
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
<span slot="headline">${provider.name}</span>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-list>
|
||||
</ha-list-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
|
||||
background: var(--card-background-color);
|
||||
padding: 0 15px;
|
||||
}
|
||||
ha-list {
|
||||
ha-list-base {
|
||||
margin: 16px -16px 0;
|
||||
--mdc-list-side-padding: 24px;
|
||||
--ha-row-item-padding-inline: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Return a copy of the list with the value in it, without adding duplicates
|
||||
* @param values - The current list
|
||||
* @param value - The value to add
|
||||
*/
|
||||
export const arrayWith = <T>(
|
||||
values: readonly T[] | undefined,
|
||||
value: T
|
||||
): T[] => (values?.includes(value) ? [...values] : [...(values ?? []), value]);
|
||||
|
||||
/**
|
||||
* Return a copy of the list without the value
|
||||
* @param values - The current list
|
||||
* @param value - The value to remove
|
||||
*/
|
||||
export const arrayWithout = <T>(
|
||||
values: readonly T[] | undefined,
|
||||
value: T
|
||||
): T[] => (values ?? []).filter((v) => v !== value);
|
||||
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
|
||||
* Called when the page of the host is navigated to while the host is
|
||||
* connected, like a second link to an open page or coming back to it. Read
|
||||
* the URL with `searchParams()` and apply it, then `write()` the state back.
|
||||
* Gets the `popstate` or `location-changed` event, or nothing when the host
|
||||
* is connected again.
|
||||
*/
|
||||
navigated: () => void;
|
||||
navigated: (ev?: Event) => void;
|
||||
/**
|
||||
* The path of the host's page, for a page that can first be connected on
|
||||
* a sub-path it sends on to itself, like an add link. Defaults to the path
|
||||
@@ -159,7 +161,7 @@ export class QueryParamsController<
|
||||
}
|
||||
// Leaving the page gets here too, before the router swaps the page
|
||||
if (this._isActive()) {
|
||||
this._options.navigated();
|
||||
this._options.navigated(ev);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface EntityFilter {
|
||||
|
||||
export type EntityFilterFunc = (entityId: string) => boolean;
|
||||
|
||||
export type EntityFilterSources = Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>;
|
||||
|
||||
const normalizeFilterArray = <T>(
|
||||
value: T | null | T[] | (T | null)[] | undefined
|
||||
): Set<T | null> | undefined => {
|
||||
@@ -157,3 +162,20 @@ export const findEntities = (
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
sources.states,
|
||||
sources.entities,
|
||||
sources.devices,
|
||||
sources.areas,
|
||||
sources.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
};
|
||||
@@ -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> contributors';
|
||||
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
|
||||
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
|
||||
@@ -129,7 +129,7 @@ export const loadStyle = async (
|
||||
).buildMapStyle(
|
||||
mapStyle.palette,
|
||||
mapStyle.options ?? {},
|
||||
mapStyle.baseColors
|
||||
mapStyle.styleOptions
|
||||
);
|
||||
|
||||
if (typeof style.sprite === "string") {
|
||||
|
||||
@@ -35,7 +35,7 @@ const styleUrls = () => {
|
||||
export const buildMapStyle = async (
|
||||
palette: MapPalette,
|
||||
options: Record<string, unknown>,
|
||||
baseColors?: Record<string, string>
|
||||
styleOptions?: Record<string, unknown>
|
||||
): Promise<StyleSpecification> => {
|
||||
const [{ osm }, resolvedUrls] = await Promise.all([
|
||||
import("@versatiles/style"),
|
||||
@@ -50,13 +50,9 @@ export const buildMapStyle = async (
|
||||
// The builder throws on an option it does not know and on a color it cannot
|
||||
// parse. Those come from a card config or a theme, so one typo would
|
||||
// otherwise drop the whole card to raster tiles. Step back to the style as it
|
||||
// ships - colors included, or the default would come out as the builder's
|
||||
// unpainted cartography - and only then to the bare palette.
|
||||
const attempts = [
|
||||
options,
|
||||
...(baseColors ? [{ colors: baseColors }] : []),
|
||||
{},
|
||||
];
|
||||
// comes - colors and adjustments included, or the default would come out as
|
||||
// the builder's unpainted cartography - and only then to the bare palette.
|
||||
const attempts = [options, ...(styleOptions ? [styleOptions] : []), {}];
|
||||
let style: StyleSpecification | undefined;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// The vector cartographies the map can draw. Each is a light and a dark
|
||||
// palette; the theme mode picks the half.
|
||||
// palette; the theme mode picks the half, unless the style only has one.
|
||||
//
|
||||
// Only the default pair is generated at build time (build-scripts/gulp/
|
||||
// map-assets.js); everything else is built in the browser from the same
|
||||
// builder, against the tiles and glyphs core's proxy already serves.
|
||||
|
||||
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
|
||||
import { NEON_MAP_COLORS_DARK, NEON_MAP_OPTIONS } from "./neon-map-palette";
|
||||
|
||||
/** Style ids, in the order the card editor offers them */
|
||||
export const MAP_STYLES = [
|
||||
@@ -15,6 +16,7 @@ export const MAP_STYLES = [
|
||||
"muted",
|
||||
"gray",
|
||||
"toner",
|
||||
"neon",
|
||||
] as const;
|
||||
|
||||
export type MapStyle = (typeof MAP_STYLES)[number];
|
||||
@@ -26,13 +28,19 @@ type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
|
||||
|
||||
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
|
||||
|
||||
/** A cartography plus the colors painted over it; only the default has any */
|
||||
/**
|
||||
* A cartography plus what is painted over it; the VersaTiles palettes have
|
||||
* nothing. The color sets say which halves a style has: both, or neither, and
|
||||
* the theme mode picks; only one, and that is the half the map draws.
|
||||
*/
|
||||
const STYLE_PALETTES: Record<
|
||||
MapStyle,
|
||||
{
|
||||
palette: VersatilesPalette;
|
||||
colors?: Record<string, string>;
|
||||
colorsDark?: Record<string, string>;
|
||||
/** Builder options the style comes with, by option group */
|
||||
options?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
> = {
|
||||
default: {
|
||||
@@ -40,6 +48,11 @@ const STYLE_PALETTES: Record<
|
||||
colors: HA_MAP_COLORS,
|
||||
colorsDark: HA_MAP_COLORS_DARK,
|
||||
},
|
||||
neon: {
|
||||
palette: "colorful",
|
||||
colorsDark: NEON_MAP_COLORS_DARK,
|
||||
options: NEON_MAP_OPTIONS,
|
||||
},
|
||||
colorful: { palette: "colorful" },
|
||||
natural: { palette: "natural" },
|
||||
muted: { palette: "muted" },
|
||||
@@ -96,11 +109,12 @@ export interface ResolvedMapStyle {
|
||||
/** Fetched rather than built: the default, while nothing was added to it */
|
||||
shipped?: "light" | "dark";
|
||||
/**
|
||||
* The style's own colors, without what the theme or the card painted over
|
||||
* them. What to fall back on when the builder rejects an adjustment, so a
|
||||
* bad value costs the adjustments rather than the cartography.
|
||||
* The style as it comes: its colors and its own adjustments, without what
|
||||
* the theme or the card painted over them. What to fall back on when the
|
||||
* builder rejects an adjustment, so a bad value costs the card's adjustments
|
||||
* rather than the style.
|
||||
*/
|
||||
baseColors?: Record<string, string>;
|
||||
styleOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isStyle = (value: unknown): value is MapStyle =>
|
||||
@@ -110,9 +124,31 @@ export const isCustomMapStyle = (
|
||||
style: MapStyleConfig | undefined
|
||||
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
|
||||
|
||||
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
|
||||
const toStyle = (style: MapStyleConfig | undefined): MapStyle => {
|
||||
const named = isCustomMapStyle(style) ? style.base : style;
|
||||
return isStyle(named) ? named : DEFAULT_MAP_STYLE;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the map comes out dark: the theme mode's answer, unless the style
|
||||
* only has the one half. What the map's own chrome and the theme tokens have
|
||||
* to follow, rather than the theme mode.
|
||||
*/
|
||||
export const isDarkMapStyle = (
|
||||
style: MapStyleConfig | undefined,
|
||||
darkMode: boolean
|
||||
): boolean => {
|
||||
const { colors, colorsDark } = STYLE_PALETTES[toStyle(style)];
|
||||
if (Boolean(colors) !== Boolean(colorsDark)) {
|
||||
return Boolean(colorsDark);
|
||||
}
|
||||
return darkMode;
|
||||
};
|
||||
|
||||
/** The half to draw; `dark` is the map's, from isDarkMapStyle */
|
||||
export const paletteFor = (style: MapStyle, dark: boolean): MapPalette => {
|
||||
const { palette } = STYLE_PALETTES[style];
|
||||
return darkMode ? `${palette}-dark` : palette;
|
||||
return dark ? `${palette}-dark` : palette;
|
||||
};
|
||||
|
||||
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
|
||||
@@ -198,19 +234,33 @@ export const resolveMapStyle = (
|
||||
themeColors?: Record<string, string>
|
||||
): ResolvedMapStyle => {
|
||||
const custom = isCustomMapStyle(style) ? style : undefined;
|
||||
const named = custom ? custom.base : style;
|
||||
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
|
||||
const mapStyle = toStyle(style);
|
||||
// A dark-only style picks the dark half of everything, not just the palette.
|
||||
const dark = isDarkMapStyle(style, darkMode);
|
||||
|
||||
const palette = paletteFor(mapStyle, darkMode);
|
||||
const options = custom ? builderOptions(custom, darkMode) : {};
|
||||
const palette = paletteFor(mapStyle, dark);
|
||||
const options = custom ? builderOptions(custom, dark) : {};
|
||||
// Before the colors below are merged in, so it answers whether the card
|
||||
// asked for anything of its own.
|
||||
const cardAdjusted = Object.keys(options).length > 0;
|
||||
|
||||
// The style's own adjustments go under the card's, group by group, so a
|
||||
// card that sets one recolor value keeps the style's others.
|
||||
const {
|
||||
colors,
|
||||
colorsDark,
|
||||
options: styleOptions,
|
||||
} = STYLE_PALETTES[mapStyle];
|
||||
for (const [group, values] of Object.entries(styleOptions ?? {})) {
|
||||
options[group] = {
|
||||
...values,
|
||||
...(options[group] as Record<string, unknown> | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// Bottom up: the style's colors, what the theme repaints, then what the card
|
||||
// names - so a card that sets one color keeps the rest of the look.
|
||||
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
|
||||
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
|
||||
const styleColors = dark ? colorsDark : colors;
|
||||
if (styleColors || themeColors) {
|
||||
options.colors = {
|
||||
...styleColors,
|
||||
@@ -223,11 +273,19 @@ export const resolveMapStyle = (
|
||||
return { palette };
|
||||
}
|
||||
|
||||
const base = styleColors ? { baseColors: styleColors } : {};
|
||||
const own = styleColors || styleOptions;
|
||||
const base = own
|
||||
? {
|
||||
styleOptions: {
|
||||
...styleOptions,
|
||||
...(styleColors && { colors: styleColors }),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
// Anything added on top of the shipped default has to be built here.
|
||||
const untouched = !themeColors && !cardAdjusted;
|
||||
return mapStyle === DEFAULT_MAP_STYLE && untouched
|
||||
? { palette, options, ...base, shipped: darkMode ? "dark" : "light" }
|
||||
? { palette, options, ...base, shipped: dark ? "dark" : "light" }
|
||||
: { palette, options, ...base };
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { isAndroidDevice } from "../../util/is_android";
|
||||
import { isIosDevice } from "../../util/is_ios";
|
||||
|
||||
export const navigationUrl = (latitude: number, longitude: number): string => {
|
||||
const point = `${latitude},${longitude}`;
|
||||
if (isIosDevice()) {
|
||||
return `https://maps.apple.com/?daddr=${point}`;
|
||||
}
|
||||
if (isAndroidDevice()) {
|
||||
return `geo:${point}?q=${point}`;
|
||||
}
|
||||
return `https://www.openstreetmap.org/directions?to=${encodeURIComponent(point)}#map=15/${latitude}/${longitude}`;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
// The "neon" style: the VersaTiles "colorful" cartography repainted as a night
|
||||
// map, glowing roads on near-black ground. A dark set and no light one, which
|
||||
// is what makes the style dark whatever the theme mode.
|
||||
|
||||
export const NEON_MAP_COLORS_DARK = {
|
||||
background: "#07021a",
|
||||
land: "#07021a",
|
||||
water: "#13215e",
|
||||
labelWater: "#00e5ff",
|
||||
glacier: "#1b2a6b",
|
||||
natureWood: "#0d1f2e",
|
||||
natureGrass: "#101a33",
|
||||
naturePark: "#12203a",
|
||||
natureLeisure: "#111a30",
|
||||
natureAgriculture: "#0d1528",
|
||||
natureWetland: "#0e2440",
|
||||
natureSand: "#1a1230",
|
||||
natureRock: "#140c28",
|
||||
areaResidential: "#0c0524",
|
||||
areaCommercial: "#140430",
|
||||
areaIndustrial: "#10062a",
|
||||
areaWaste: "#160a2e",
|
||||
areaBurial: "#0d0a26",
|
||||
siteParking: "#0f0728",
|
||||
siteSports: "#0a1c2c",
|
||||
siteEducation: "#160a34",
|
||||
siteHospital: "#2a0a2a",
|
||||
siteDanger: "#ff1744",
|
||||
sitePrison: "#1c0a28",
|
||||
siteConstruction: "#3a1d5c",
|
||||
building: "#160a33",
|
||||
buildingBg: "#8a2be2",
|
||||
roadStreet: "#00e5ff",
|
||||
roadStreetBg: "#062b52",
|
||||
roadSecondary: "#8f7bff",
|
||||
roadSecondaryBg: "#241058",
|
||||
roadTrunk: "#ff6ec7",
|
||||
roadTrunkBg: "#4a0f38",
|
||||
roadMotorway: "#ff2d95",
|
||||
roadMotorwayBg: "#5c0033",
|
||||
transitRail: "#7b2cff",
|
||||
transitSubway: "#b14cff",
|
||||
transitCycle: "#00ffa3",
|
||||
transitFoot: "#2e2a55",
|
||||
boundary: "#39ff14",
|
||||
boundaryDisputed: "#1f7a0d",
|
||||
label: "#ff9ad5",
|
||||
labelHalo: "rgba(7,2,26,0.9)",
|
||||
labelSymbol: "#ff9ad5",
|
||||
labelPoi: "#00e5ff",
|
||||
labelShield: "#12002a",
|
||||
labelHousenumber: "rgba(0,229,255,0.35)",
|
||||
};
|
||||
|
||||
/** Builder options the style comes with, under whatever a card adds */
|
||||
export const NEON_MAP_OPTIONS = {
|
||||
recolor: { saturate: 0.35, contrast: 1.15 },
|
||||
text: {
|
||||
transform: "uppercase",
|
||||
letterSpacing: 0.12,
|
||||
haloWidth: 2,
|
||||
haloBlur: 0,
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type {
|
||||
ListItem,
|
||||
RequestSelectedDetail,
|
||||
} from "@material/mwc-list/mwc-list-item";
|
||||
|
||||
export const shouldHandleRequestSelectedEvent = (
|
||||
ev: CustomEvent<RequestSelectedDetail>
|
||||
): boolean => {
|
||||
if (!ev.detail.selected || ev.detail.source !== "property") {
|
||||
return false;
|
||||
}
|
||||
(ev.currentTarget as ListItem).selected = false;
|
||||
return true;
|
||||
};
|
||||
+13
-2
@@ -16,6 +16,13 @@ declare global {
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
/**
|
||||
* Leave open dialogs open. For a replace that only canonicalizes the current
|
||||
* URL, like a router filling in its default page: a dialog restored from the
|
||||
* URL, such as a more-info deep link, may already be open at that point, and
|
||||
* the entry keeps its state so back still closes it.
|
||||
*/
|
||||
keepDialogs?: boolean;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
@@ -196,7 +203,8 @@ const buildHistoryState = (
|
||||
};
|
||||
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
const keepDialogs = options?.keepDialogs || false;
|
||||
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
}
|
||||
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
if (replace && keepDialogs) {
|
||||
// Same entry under its canonical URL: its state, dialog included, stays.
|
||||
history.replaceState(history.state, "", path);
|
||||
} else if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
|
||||
@@ -20,6 +20,7 @@ export const entitiesFilterQueryParamConfig = {
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"device_class",
|
||||
"domain",
|
||||
"entity_domain",
|
||||
"label",
|
||||
@@ -50,6 +51,7 @@ 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"]),
|
||||
@@ -114,6 +116,7 @@ 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,3 +1,4 @@
|
||||
import { SYSTEM_LOG_LEVELS, type SystemLogLevel } from "../../data/system_log";
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
@@ -17,7 +18,7 @@ export const logSearchQueryParamConfig = {
|
||||
// `raw` shows the full Home Assistant Core log instead of the errors.
|
||||
export const logsQueryParamConfig = {
|
||||
string: ["provider", "search", "filter"],
|
||||
list: ["integration"],
|
||||
list: ["integration", "level"],
|
||||
boolean: [{ key: "raw", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
@@ -28,13 +29,19 @@ export interface LogsState {
|
||||
search: string;
|
||||
// Only apply to the Home Assistant Core log
|
||||
integrations: string[];
|
||||
// Only apply to the Home Assistant Core errors, not the raw log
|
||||
levels: SystemLogLevel[];
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
const isLevel = (level: string): level is SystemLogLevel =>
|
||||
(SYSTEM_LOG_LEVELS as readonly string[]).includes(level);
|
||||
|
||||
export const DEFAULT_LOGS_STATE: LogsState = {
|
||||
provider: DEFAULT_LOG_PROVIDER,
|
||||
search: "",
|
||||
integrations: [],
|
||||
levels: [],
|
||||
raw: false,
|
||||
};
|
||||
|
||||
@@ -53,6 +60,7 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
|
||||
search: state.search,
|
||||
integration:
|
||||
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
|
||||
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
|
||||
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
|
||||
});
|
||||
|
||||
@@ -71,6 +79,7 @@ export const logsLinkFromUrl = (
|
||||
provider,
|
||||
search: params.search ?? params.filter ?? "",
|
||||
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
|
||||
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
|
||||
raw: core && !!params.raw,
|
||||
};
|
||||
};
|
||||
@@ -67,3 +67,88 @@ export const itemTooltipPosition: TooltipPositionCallback = (
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const ECHARTS_TOOLTIP_GAP_PX = 20;
|
||||
export const WINDOW_MARGIN_PX = 8;
|
||||
|
||||
const fitInto = (
|
||||
pos: number,
|
||||
size: number,
|
||||
alternatives: number[],
|
||||
min: number,
|
||||
max: number
|
||||
) =>
|
||||
size > max - min
|
||||
? min
|
||||
: ([pos, ...alternatives].find((p) => p >= min && p + size <= max) ??
|
||||
Math.max(min, Math.min(pos, max - size)));
|
||||
|
||||
/**
|
||||
* ECharts' default placement (or the chart-confined one), moved into the
|
||||
* window when it would leave it. The other side of the cursor is tried before
|
||||
* clamping, below first vertically. A tooltip taller than the window starts at
|
||||
* its top. A confined tooltip stays in the chart, leaving it only as far as the
|
||||
* window needs.
|
||||
*/
|
||||
export const windowTooltipPosition =
|
||||
(
|
||||
getChartRect: () => DOMRect | undefined,
|
||||
confine: boolean
|
||||
): TooltipPositionCallback =>
|
||||
(point, _params, _dom, _rect, size) => {
|
||||
const [cursorX, cursorY] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const right = cursorX + ECHARTS_TOOLTIP_GAP_PX;
|
||||
const left = cursorX - ECHARTS_TOOLTIP_GAP_PX - tipW;
|
||||
const below = cursorY + ECHARTS_TOOLTIP_GAP_PX;
|
||||
const above = cursorY - ECHARTS_TOOLTIP_GAP_PX - tipH;
|
||||
let x = right + tipW + 2 > viewW ? left : right;
|
||||
let y = below + tipH > viewH ? above : below;
|
||||
if (confine) {
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
y = Math.max(0, Math.min(y, viewH - tipH));
|
||||
}
|
||||
|
||||
const rect = getChartRect();
|
||||
if (!rect?.height) {
|
||||
return [x, y];
|
||||
}
|
||||
const fit = (
|
||||
pos: number,
|
||||
tipSize: number,
|
||||
alternatives: number[],
|
||||
windowMin: number,
|
||||
windowMax: number,
|
||||
viewSize: number
|
||||
) => {
|
||||
if (!confine) {
|
||||
return fitInto(pos, tipSize, alternatives, windowMin, windowMax);
|
||||
}
|
||||
const min = Math.max(0, windowMin);
|
||||
const max = Math.min(viewSize, windowMax);
|
||||
return tipSize > max - min
|
||||
? fitInto(pos, tipSize, [], windowMin, windowMax)
|
||||
: fitInto(pos, tipSize, alternatives, min, max);
|
||||
};
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
return [
|
||||
fit(
|
||||
x,
|
||||
tipW,
|
||||
[right, left],
|
||||
WINDOW_MARGIN_PX - rect.left,
|
||||
clientWidth - WINDOW_MARGIN_PX - rect.left,
|
||||
viewW
|
||||
),
|
||||
fit(
|
||||
y,
|
||||
tipH,
|
||||
[below, above],
|
||||
WINDOW_MARGIN_PX - rect.top,
|
||||
clientHeight - WINDOW_MARGIN_PX - rect.top,
|
||||
viewH
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -68,6 +68,7 @@ import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import type { ChartSonification } from "./chart-sonification";
|
||||
import { canSonifyChart, sonifyChart } from "./chart-sonification";
|
||||
import { windowTooltipPosition } from "./chart-tooltip-position";
|
||||
import { downSampleLineData } from "./down-sample";
|
||||
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
|
||||
|
||||
@@ -1252,9 +1253,19 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
|
||||
const next = convertHaTooltipFormatter(tooltip);
|
||||
if (isMobile) {
|
||||
next.appendTo = undefined;
|
||||
}
|
||||
if (next.position === undefined) {
|
||||
// ECharts confines after the position callback, which would undo the
|
||||
// window fit, so the callback confines itself
|
||||
next.position = windowTooltipPosition(
|
||||
() => this._chartContainer?.getBoundingClientRect(),
|
||||
isMobile || next.confine === true
|
||||
);
|
||||
next.confine = false;
|
||||
} else if (isMobile) {
|
||||
// mobile charts are full width so we need to confine the tooltip to the chart
|
||||
next.confine = true;
|
||||
next.appendTo = undefined;
|
||||
}
|
||||
if (isMobile || this._touchInput) {
|
||||
next.triggerOn = "click";
|
||||
|
||||
@@ -31,8 +31,14 @@ export interface NetworkNode {
|
||||
color?: string;
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
opacity?: number;
|
||||
};
|
||||
label?: {
|
||||
position?: "left" | "right" | "top" | "bottom";
|
||||
show?: boolean;
|
||||
fontWeight?: "normal" | "bold";
|
||||
fontSize?: number;
|
||||
};
|
||||
label?: { position: "left" | "right" | "top" | "bottom" };
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* Distance from the center, where 0 is the center and 1 is the edge
|
||||
@@ -51,6 +57,7 @@ export interface NetworkLink {
|
||||
width?: number;
|
||||
color?: string;
|
||||
type?: "solid" | "dashed" | "dotted";
|
||||
opacity?: number;
|
||||
};
|
||||
symbolSize?: number | number[];
|
||||
symbol?: string;
|
||||
@@ -286,9 +293,8 @@ export class HaNetworkGraph extends LitElement {
|
||||
return node.name ?? "";
|
||||
},
|
||||
rich: {
|
||||
primary: {
|
||||
fontSize: 12,
|
||||
},
|
||||
// inherits the node label font, so a node can override it
|
||||
primary: {},
|
||||
secondary: {
|
||||
fontSize: 12,
|
||||
color: getComputedStyle(document.body).getPropertyValue(
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Node {
|
||||
color?: string;
|
||||
passThrough?: boolean;
|
||||
entityId?: string;
|
||||
// Nodes of one group take a single position when ordering their children
|
||||
group?: string;
|
||||
}
|
||||
export interface Link {
|
||||
source: string;
|
||||
@@ -46,6 +48,46 @@ const NODE_SIZE = 30;
|
||||
const LABEL_MIN_MARGIN = 5;
|
||||
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
||||
|
||||
// Links without a value take the remaining capacity of their nodes, in order.
|
||||
export const fillSankeyLinkValues = (
|
||||
nodes: Node[],
|
||||
rawLinks: Link[]
|
||||
): ProcessedLink[] => {
|
||||
const accountedIn = new Map<string, number>();
|
||||
const accountedOut = new Map<string, number>();
|
||||
const links: ProcessedLink[] = [];
|
||||
rawLinks.forEach((link) => {
|
||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||
const targetNode = nodes.find((n) => n.id === link.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return;
|
||||
}
|
||||
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
|
||||
const targetAccounted = accountedIn.get(targetNode.id) || 0;
|
||||
|
||||
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
|
||||
const sourceRemaining = sourceNode.value - sourceAccounted;
|
||||
const targetRemaining = targetNode.value - targetAccounted;
|
||||
// ensure the value is not greater than the remaining capacity of the nodes
|
||||
const value = Math.min(
|
||||
link.value ?? sourceRemaining,
|
||||
sourceRemaining,
|
||||
targetRemaining
|
||||
);
|
||||
|
||||
accountedIn.set(targetNode.id, targetAccounted + value);
|
||||
accountedOut.set(sourceNode.id, sourceAccounted + value);
|
||||
|
||||
if (value > 0) {
|
||||
links.push({
|
||||
...link,
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
return links;
|
||||
};
|
||||
|
||||
@customElement("ha-sankey-chart")
|
||||
export class HaSankeyChart extends LitElement {
|
||||
@property({ attribute: false }) public data: SankeyChartData = {
|
||||
@@ -190,7 +232,7 @@ export class HaSankeyChart extends LitElement {
|
||||
);
|
||||
}
|
||||
});
|
||||
const links = this._processLinks(filteredNodes, data.links);
|
||||
const links = fillSankeyLinkValues(filteredNodes, data.links);
|
||||
const sectionWidth = width / indexes.length;
|
||||
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
|
||||
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
|
||||
@@ -304,60 +346,31 @@ export class HaSankeyChart extends LitElement {
|
||||
|
||||
private _createData = memoizeOne(this._computeData);
|
||||
|
||||
private _processLinks(nodes: Node[], rawLinks: Link[]) {
|
||||
const accountedIn = new Map<string, number>();
|
||||
const accountedOut = new Map<string, number>();
|
||||
const links: ProcessedLink[] = [];
|
||||
rawLinks.forEach((link) => {
|
||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||
const targetNode = nodes.find((n) => n.id === link.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return;
|
||||
}
|
||||
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
|
||||
const targetAccounted = accountedIn.get(targetNode.id) || 0;
|
||||
|
||||
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
|
||||
const sourceRemaining = sourceNode.value - sourceAccounted;
|
||||
const targetRemaining = targetNode.value - targetAccounted;
|
||||
// ensure the value is not greater than the remaining capacity of the nodes
|
||||
const value = Math.min(
|
||||
link.value ?? sourceRemaining,
|
||||
sourceRemaining,
|
||||
targetRemaining
|
||||
);
|
||||
|
||||
accountedIn.set(targetNode.id, targetAccounted + value);
|
||||
accountedOut.set(sourceNode.id, sourceAccounted + value);
|
||||
|
||||
if (value > 0) {
|
||||
links.push({
|
||||
...link,
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
|
||||
const parents = links.filter((l) => l.target === id).map((l) => l.source);
|
||||
if (parents.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
const countedGroups = new Set<string>();
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const parent of parents) {
|
||||
let offset = 0;
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
const section = sections[i];
|
||||
const index = section.findIndex((n) => n.id === parent);
|
||||
if (index !== -1) {
|
||||
sum += offset + index;
|
||||
const positions = [...new Set(sections[i].map((n) => n.group ?? n.id))];
|
||||
const node = sections[i].find((n) => n.id === parent);
|
||||
if (node) {
|
||||
if (node.group !== undefined) {
|
||||
if (countedGroups.has(node.group)) {
|
||||
break;
|
||||
}
|
||||
countedGroups.add(node.group);
|
||||
}
|
||||
sum += offset + positions.indexOf(node.group ?? node.id);
|
||||
count++;
|
||||
break;
|
||||
}
|
||||
offset += section.length;
|
||||
offset += positions.length;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : -1;
|
||||
|
||||
@@ -12,11 +12,11 @@ import { haStyleDialog } from "../../resources/styles";
|
||||
import "../ha-button";
|
||||
import "../ha-dialog-footer";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../ha-sortable";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-dialog";
|
||||
import "../item/ha-list-item-base";
|
||||
import "../list/ha-list-base";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableColumnData,
|
||||
@@ -139,7 +139,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
draggable-selector=".draggable"
|
||||
handle-selector=".handle"
|
||||
>
|
||||
<ha-list>
|
||||
<ha-list-base>
|
||||
${repeat(
|
||||
columns,
|
||||
(col) => col.key,
|
||||
@@ -152,23 +152,23 @@ export class DialogDataTableSettings extends LitElement {
|
||||
col.defaultHidden)
|
||||
: col.defaultHidden);
|
||||
|
||||
return html`<ha-list-item
|
||||
hasMeta
|
||||
return html`<ha-list-item-base
|
||||
class=${classMap({
|
||||
hidden: !isVisible,
|
||||
draggable: canMove && isVisible,
|
||||
})}
|
||||
graphic="icon"
|
||||
noninteractive
|
||||
>${col.title || col.label || col.key}
|
||||
>
|
||||
<span slot="headline"
|
||||
>${col.title || col.label || col.key}</span
|
||||
>
|
||||
${
|
||||
canMove && isVisible
|
||||
? html`<ha-svg-icon
|
||||
class="handle"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
slot="graphic"
|
||||
slot="start"
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
: html`<span class="handle-spacer" slot="start"></span>`
|
||||
}
|
||||
<ha-icon-button
|
||||
tabindex="0"
|
||||
@@ -176,7 +176,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
.disabled=${!canHide}
|
||||
.hidden=${!isVisible}
|
||||
.path=${isVisible ? mdiEye : mdiEyeOff}
|
||||
slot="meta"
|
||||
slot="end"
|
||||
.label=${localize(
|
||||
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
|
||||
{ title: typeof col.title === "string" ? col.title : "" }
|
||||
@@ -184,10 +184,10 @@ export class DialogDataTableSettings extends LitElement {
|
||||
.column=${col.key}
|
||||
@click=${this._toggle}
|
||||
></ha-icon-button>
|
||||
</ha-list-item>`;
|
||||
</ha-list-item-base>`;
|
||||
}
|
||||
)}
|
||||
</ha-list>
|
||||
</ha-list-base>
|
||||
</ha-sortable>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@@ -326,8 +326,8 @@ export class DialogDataTableSettings extends LitElement {
|
||||
--dialog-z-index: 10;
|
||||
--dialog-content-padding: 0 8px;
|
||||
}
|
||||
ha-list-item {
|
||||
--mdc-list-side-padding: 12px;
|
||||
ha-list-item-base {
|
||||
--ha-row-item-padding-inline: 12px;
|
||||
overflow: visible;
|
||||
}
|
||||
.hidden {
|
||||
@@ -345,6 +345,11 @@ export class DialogDataTableSettings extends LitElement {
|
||||
display: block;
|
||||
margin: -12px;
|
||||
}
|
||||
.handle-spacer {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
@@ -124,16 +125,16 @@ export interface DataTableRowData {
|
||||
|
||||
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
|
||||
// Slot for the actions at the end of a group's header, they don't toggle the
|
||||
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
|
||||
// Renders the actions at the end of a group's header, they don't toggle the
|
||||
// group. Return `ha-icon-button`s with a label. They get the rows' icon
|
||||
// colour and size by default, which `--data-table-group-actions-color`,
|
||||
// `--data-table-group-actions-button-size` and
|
||||
// `--data-table-group-actions-icon-size` change for all of them; style a
|
||||
// button itself for its own colour or size.
|
||||
export const GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
|
||||
|
||||
export const groupActionsSlot = (group: string) =>
|
||||
`${GROUP_ACTIONS_SLOT_PREFIX}${group}`;
|
||||
// button itself for its own colour or size. Not a slot per group, as WebKit
|
||||
// is slow to add and remove slots while the rows scroll.
|
||||
export type DataTableGroupActions = (
|
||||
group: string
|
||||
) => TemplateResult | typeof nothing;
|
||||
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
|
||||
@@ -197,6 +198,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public groupOrder?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderGroupActions?: DataTableGroupActions;
|
||||
|
||||
@property({ attribute: false }) public sortColumn?: string;
|
||||
|
||||
@property({ attribute: false }) public sortDirection: SortingDirection = null;
|
||||
@@ -506,7 +510,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -759,8 +764,6 @@ export class HaDataTable extends LitElement {
|
||||
`
|
||||
: html`
|
||||
${
|
||||
// Rendered before the rows, so the group's actions slot
|
||||
// shows here and not in its scrolled out header row
|
||||
this.groupColumn &&
|
||||
!this.autoHeight &&
|
||||
this._stickyGroup !== undefined
|
||||
@@ -775,11 +778,15 @@ export class HaDataTable extends LitElement {
|
||||
.group=${this._stickyGroup}
|
||||
@click=${this._collapseStickyGroup}
|
||||
>
|
||||
${this._renderGroupHeader(
|
||||
${keyed(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
true
|
||||
this._renderGroupHeader(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
this.renderGroupActions,
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
@@ -803,7 +810,14 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (row: DataTableRowData) => row?.[this.id] || row;
|
||||
// Group headers are keyed by their group, so they keep their actions' state
|
||||
// when the data changes. The prefixes keep them apart from row ids.
|
||||
private _keyFunction = (row: DataTableRowData) =>
|
||||
row?.[this.id]
|
||||
? `row-${row[this.id]}`
|
||||
: row?.groupHeader !== undefined
|
||||
? `group-${row.groupHeader}`
|
||||
: row;
|
||||
|
||||
private _renderMainValue(value: unknown) {
|
||||
return this.clickable
|
||||
@@ -1063,7 +1077,8 @@ export class HaDataTable extends LitElement {
|
||||
groupOrder: string[] | undefined,
|
||||
collapsedGroups: string[],
|
||||
sortColumn: string | undefined,
|
||||
sortDirection: SortingDirection
|
||||
sortDirection: SortingDirection,
|
||||
renderGroupActions: DataTableGroupActions | undefined
|
||||
) => {
|
||||
if (appendRow || groupColumn) {
|
||||
let items = [...data];
|
||||
@@ -1122,7 +1137,8 @@ export class HaDataTable extends LitElement {
|
||||
content: this._renderGroupHeader(
|
||||
groupName,
|
||||
collapsedGroups.includes(groupName),
|
||||
localize
|
||||
localize,
|
||||
renderGroupActions
|
||||
),
|
||||
});
|
||||
if (!collapsedGroups.includes(groupName)) {
|
||||
@@ -1148,6 +1164,7 @@ export class HaDataTable extends LitElement {
|
||||
groupName: string,
|
||||
collapsed: boolean,
|
||||
localize: LocalizeFunc | undefined,
|
||||
renderGroupActions: DataTableGroupActions | undefined,
|
||||
sticky = false
|
||||
) {
|
||||
// The sticky copy's button and name are hidden from assistive technology,
|
||||
@@ -1183,7 +1200,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
</span>
|
||||
<div class="group-actions" @click=${this._stopGroupToggle}>
|
||||
<slot name=${groupActionsSlot(groupName)}></slot>
|
||||
${renderGroupActions?.(groupName)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1264,7 +1281,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -1961,7 +1979,7 @@ export class HaDataTable extends LitElement {
|
||||
align-self: stretch;
|
||||
margin-top: -12px;
|
||||
margin-inline-start: auto;
|
||||
/* The rows' icon button size, inherited by the slotted ones */
|
||||
/* The rows' icon button size, inherited by the actions */
|
||||
--_button-size: var(--data-table-group-actions-button-size, 48px);
|
||||
--ha-icon-button-size: var(--_button-size);
|
||||
--mdc-icon-size: var(--data-table-group-actions-icon-size, 24px);
|
||||
@@ -1974,9 +1992,9 @@ export class HaDataTable extends LitElement {
|
||||
position: sticky;
|
||||
inset-inline-end: 0;
|
||||
cursor: default;
|
||||
/* Don't pass the group name's weight on to slotted menus */
|
||||
/* Don't pass the group name's weight on to the actions */
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
/* Inherited by the slotted icon buttons, like the rows' icon cells */
|
||||
/* Inherited by the actions, like the rows' icon cells */
|
||||
color: var(
|
||||
--data-table-group-actions-color,
|
||||
var(--secondary-text-color)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { ActionDetail } from "@material/mwc-list";
|
||||
import { mdiCalendarToday } from "@mdi/js";
|
||||
import "cally";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../common/dom/fire_event";
|
||||
@@ -32,8 +32,8 @@ import type { HaBaseTimeInput } from "../ha-base-time-input";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../item/ha-list-item-button";
|
||||
import "../list/ha-list-base";
|
||||
import "../ha-time-input";
|
||||
import type { HaTimeInput } from "../ha-time-input";
|
||||
import type { DateRangePickerRanges } from "./ha-date-range-picker";
|
||||
@@ -166,11 +166,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list @action=${this._setDateRange} activatable>
|
||||
${Object.keys(this.ranges!).map(
|
||||
(name) => html`<ha-list-item>${name}</ha-list-item>`
|
||||
<ha-list-base>
|
||||
${Object.entries(this.ranges!).map(
|
||||
([name, range], index) => html`
|
||||
<ha-list-item-button
|
||||
.range=${range}
|
||||
.index=${index}
|
||||
@click=${this._setDateRange}
|
||||
>
|
||||
<span slot="headline">${name}</span>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-list>
|
||||
</ha-list-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -343,11 +351,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
this._saveDateRangePreset(chip.range, chip.index);
|
||||
}
|
||||
|
||||
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
|
||||
const dateRange: [Date, Date] = Object.values(this.ranges!)[
|
||||
ev.detail.index
|
||||
];
|
||||
this._saveDateRangePreset(dateRange, ev.detail.index);
|
||||
private _setDateRange(
|
||||
ev: HASSDomCurrentTargetEvent<
|
||||
HTMLElement & { range: [Date, Date]; index: number }
|
||||
>
|
||||
) {
|
||||
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
|
||||
}
|
||||
|
||||
private _saveDateRangePreset(range: [Date, Date], index: number) {
|
||||
|
||||
@@ -161,7 +161,8 @@ export abstract class HaDeviceAutomationPicker<
|
||||
return html`<ha-generic-picker
|
||||
.label=${this.label}
|
||||
.value=${value}
|
||||
.disabled=${!this._automations || this._automations.length === 0}
|
||||
.disabled=${this._automations?.length === 0}
|
||||
.loading=${this._automations === undefined}
|
||||
.getItems=${this._getItems(
|
||||
value,
|
||||
this._automations,
|
||||
|
||||
@@ -189,6 +189,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _loadFailed = false;
|
||||
|
||||
private _statisticIdsRequest = 0;
|
||||
|
||||
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -201,11 +205,36 @@ export class HaStatisticPicker extends LitElement {
|
||||
}
|
||||
|
||||
private async _getStatisticIds() {
|
||||
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
|
||||
// Changing the statistic types starts a new request, and only the latest
|
||||
// one may update the picker
|
||||
const request = ++this._statisticIdsRequest;
|
||||
this._loadFailed = false;
|
||||
|
||||
let statisticIds: StatisticsMetaData[];
|
||||
try {
|
||||
statisticIds = await getStatisticIds(this._api, this.statisticTypes);
|
||||
} catch {
|
||||
if (request === this._statisticIdsRequest) {
|
||||
this._loadFailed = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (request !== this._statisticIdsRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statisticIds = statisticIds;
|
||||
this._picker?.requestUpdate();
|
||||
this._valueRenderer = this._makeValueRenderer();
|
||||
}
|
||||
|
||||
private _retryGetStatisticIds(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._getStatisticIds();
|
||||
}
|
||||
|
||||
private _getItems = () =>
|
||||
this._getStatisticsItems(
|
||||
this._states,
|
||||
@@ -589,6 +618,12 @@ export class HaStatisticPicker extends LitElement {
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.loading=${!this._loadFailed && this.statisticIds === undefined}
|
||||
.loadError=${
|
||||
this._loadFailed
|
||||
? this._i18n.localize("ui.components.statistic-picker.load_failed")
|
||||
: undefined
|
||||
}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@@ -598,6 +633,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
"ui.components.statistic-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
@picker-retry=${this._retryGetStatisticIds}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
@@ -21,8 +22,10 @@ import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import {
|
||||
brandsAccessTokenContext,
|
||||
connectionContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import { isBrandUrl } from "../../util/brands-url";
|
||||
import type { HomeAssistantUI } from "../../types";
|
||||
import { darkBrandUrl, isBrandUrl } from "../../util/brands-url";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("state-badge")
|
||||
@@ -56,6 +59,13 @@ export class StateBadge extends LitElement {
|
||||
// @ts-ignore read in willUpdate through changedProps only
|
||||
private _brandsAccessToken?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, boolean | undefined>({
|
||||
transformer: (ui) => ui?.themes?.darkMode,
|
||||
})
|
||||
private _darkMode?: boolean;
|
||||
|
||||
@state() private _iconStyle: Record<string, string | undefined> = {};
|
||||
|
||||
connectedCallback(): void {
|
||||
@@ -141,7 +151,9 @@ export class StateBadge extends LitElement {
|
||||
!changedProps.has("stateColor") &&
|
||||
!changedProps.has("color") &&
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_brandsAccessToken")
|
||||
!changedProps.has("_brandsAccessToken") &&
|
||||
// Brand images have a dark variant, so the picture changes with the theme
|
||||
!changedProps.has("_darkMode")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -217,15 +229,17 @@ export class StateBadge extends LitElement {
|
||||
// Sign the image URL via the connection context so brand images
|
||||
// (/api/brands/...) get their access token. Without a way to sign, a brands
|
||||
// request would be rejected (and logged/blocked by core), so skip it until
|
||||
// we can sign.
|
||||
// we can sign. In dark mode, brand images (e.g. the integration icon of an
|
||||
// update entity) use their dark-optimized variant.
|
||||
private _resolveImageUrl(url: string | undefined): string {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
const themedUrl = this._darkMode ? darkBrandUrl(url) : url;
|
||||
if (this._connection) {
|
||||
return this._connection.hassUrl(url);
|
||||
return this._connection.hassUrl(themedUrl);
|
||||
}
|
||||
return isBrandUrl(url) ? "" : url;
|
||||
return isBrandUrl(themedUrl) ? "" : themedUrl;
|
||||
}
|
||||
|
||||
protected getClass() {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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,10 +26,7 @@ import "./ha-sortable";
|
||||
|
||||
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
type ClockDateSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
type ClockDateSeparatorPart = Extract<ClockCardDatePart, `separator-${string}`>;
|
||||
|
||||
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
|
||||
"day",
|
||||
@@ -43,6 +40,7 @@ const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-comma": ",",
|
||||
"separator-new-line": "",
|
||||
};
|
||||
|
||||
|
||||
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user