Compare commits

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

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

Keep the v1 behaviour the callers rely on: the selection cannot leave
the image or the visible canvas, the image cannot be moved or zoomed out
from under it (viewMode 1), resizing stops at 50px, the rounded preview
is CSS only so the exported file stays rectangular, and the export is
rendered at the source resolution. Dirty tracking compares the crop
rectangle in source image pixels, like getData() did in v1.
2026-11-02 21:32:33 +01:00
371 changed files with 4967 additions and 15152 deletions

No files matched your search

-4
View File
@@ -12,7 +12,3 @@
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
# on a conflict, restore it from the base branch and run `gh actions-lock`.
.github/workflows/actions.lock linguist-language=YAML -merge
-6
View File
@@ -1,6 +0,0 @@
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
# https://github.com/rhysd/actionlint/issues/711
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
@@ -24,7 +24,7 @@ runs:
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
cache: false
-41
View File
@@ -1,41 +0,0 @@
name: Verify actions lockfile
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
name: Verify actions lockfile
runs-on: ubuntu-latest
env:
GH_ACTIONS_LOCK_VERSION: v0.4.1
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
- name: Verify lockfile
env:
GH_TOKEN: ${{ github.token }}
run: gh actions-lock --verify
-190
View File
@@ -1,190 +0,0 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/actionlint.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/actions-lock.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/cast_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/ci.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/codeql-analysis.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
'.github/workflows/copilot-setup-steps.yml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/demo_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/dependency-review.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/design_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/design_preview.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/e2e.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/labeler.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
'.github/workflows/lock.yaml':
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
'.github/workflows/nightly.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/pull-request-standards.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/relative-ci.yaml':
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
'.github/workflows/release-drafter.yaml':
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
'.github/workflows/release.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
'.github/workflows/restrict-task-creation.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/stale.yaml':
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
'.github/workflows/sync-device-classes.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/sync-sensor-entity-constants.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/translations.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
dependencies:
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
ref: 'v5.0.0'
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
owner_id: 44036562
repo_id: 476372928
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
ref: 'v9.0.0'
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
owner_id: 44036562
repo_id: 205262760
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
ref: 'v7.0.0'
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
owner_id: 44036562
repo_id: 177139928
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
ref: 'v7.1.0'
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
owner_id: 44036562
repo_id: 192625525
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
ref: 'v6.2.0'
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
owner_id: 44036562
repo_id: 192625525
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
ref: 'v11.0.0'
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
owner_id: 44036562
repo_id: 200437066
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
ref: 'v6.0.2'
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
owner_id: 7513068
repo_id: 113918130
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
ref: 'v4.39.1'
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
owner_id: 9919
repo_id: 259445878
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
ref: 'master'
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
owner_id: 13844975
repo_id: 173703505
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
ref: '2026.07.0'
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
owner_id: 13844975
repo_id: 184234812
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
ref: 'v8.1.1'
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
owner_id: 18365890
repo_id: 197176365
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
ref: 'v1.14.3'
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
owner_id: 647025
repo_id: 178055147
uses:
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
ref: 'v3.2.4'
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
owner_id: 43522883
repo_id: 398376354
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
ref: 'v7.10.0'
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
owner_id: 58292376
repo_id: 139022359
+8 -8
View File
@@ -18,7 +18,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Deploy Development
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -30,17 +30,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Cast
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Deploy Production
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Cast
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
lint:
name: Lint and check format
@@ -43,7 +43,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
@@ -89,7 +89,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
@@ -110,7 +110,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
@@ -127,7 +127,7 @@ jobs:
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
+3 -38
View File
@@ -22,45 +22,10 @@ jobs:
with:
persist-credentials: false
# These steps mirror .github/actions/setup with node-modules-cache enabled.
# Copilot runs this workflow in a context that can't resolve `$/`
# self-repository actions, so the steps are inlined here.
- name: Restore complete dependency tree
id: dependency-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
path: |
node_modules
~/.local/share/pnpm/store
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
id: pnpm-store
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.pnpm-store.outputs.path
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
+8 -8
View File
@@ -19,7 +19,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Demo Development
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.unique_deploy_url }}
@@ -31,17 +31,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Demo Production
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -25,17 +25,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -29,17 +29,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
+16 -16
View File
@@ -32,13 +32,13 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
defaults:
run:
@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -97,12 +97,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -128,12 +128,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -176,7 +176,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -230,7 +230,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -326,7 +326,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
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
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
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
@@ -146,7 +146,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
-3
View File
@@ -284,7 +284,6 @@ module.exports.config = {
name: "demo" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
...module.exports.mapWorkerEntry(),
},
outputPath: outputPath(paths.demo_output_root, latestBuild),
@@ -292,8 +291,6 @@ module.exports.config = {
defineOverlay: {
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
__DEMO__: true,
// The embed sets the global to load static files from its own server.
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
},
isProdBuild,
latestBuild,
+1 -5
View File
@@ -232,11 +232,7 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-demo-dev",
+8 -18
View File
@@ -214,18 +214,16 @@ const createRspackConfig = ({
},
},
{
// MapLibre's worker loads ESM plugins through `import(url)`, which
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
// fully dynamic request is exactly what should remain - rejecting,
// and free of syntax the legacy floor cannot parse - so the warning
// about it is noise. The page side builds a blob import from
// `new URL(url, import.meta.url)` for a worker on another origin,
// as in the demo embed. That is a URL, not an asset to bundle, and
// the worker URL is absolute, so leave it as it is.
// MapLibre's worker loads ESM plugins through `import(url)`, and the
// page side builds a blob import from `new URL(url, import.meta.url)`
// for a cross-origin worker. Neither path is taken here: the RTL
// plugin is UMD and the worker is served from the page's origin.
// Rspack's stubs for these fully dynamic requests are exactly what
// should remain - rejecting, and free of syntax the legacy floor
// cannot parse - so the warnings about them are noise.
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
parser: {
exprContextCritical: false,
url: false,
},
},
{
@@ -248,15 +246,7 @@ const createRspackConfig = ({
},
],
},
// The demo embed reads its own URL at runtime to find its files.
// Keep import.meta.url instead of replacing it with the source path.
// Development builds wrap modules in eval(), which rejects import.meta.
latestBuild &&
isProdBuild && {
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
parser: { importMeta: false },
},
].filter(Boolean),
],
},
optimization: {
minimizer: [
+49 -42
View File
@@ -1,7 +1,7 @@
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -13,14 +13,12 @@ import {
enableWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { atLeastVersion } from "../../../../src/common/config/version";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-selectable";
import {
getLegacyLovelaceCollection,
getLovelaceCollection,
@@ -54,9 +52,6 @@ class HcCast extends LitElement {
`
: undefined;
const views = this.lovelaceViews ?? [{ title: "Home" }];
const hasIcons = views.some((view) => view.icon);
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
@@ -97,41 +92,45 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
view.icon
? html`
<ha-icon
.icon=${view.icon}
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item-option>
</ha-list-item>
`
)}
</ha-list-selectable>
)}</ha-list
>
`
}
@@ -194,6 +193,14 @@ class HcCast extends LitElement {
});
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
@@ -207,13 +214,8 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
// for the selected view, so the current view can still be cast again.
private async _handlePickView(
ev: HASSDomCurrentTargetEvent<HaListItemOption>
) {
const index = Number(ev.currentTarget.dataset.index);
const path = this.lovelaceViews?.[index]?.path ?? index;
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
}
@@ -268,11 +270,16 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
-7
View File
@@ -16,10 +16,3 @@
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
-4
View File
@@ -1,4 +0,0 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
-173
View File
@@ -1,173 +0,0 @@
import { ContextEvent } from "@lit/context";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import type { ScriptObject } from "./script-data";
import { readScriptObject } from "./script-data";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so an element inside another component still
// finds the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* The base of the embed elements that render dashboard content. The element
* uses the provider with the ID in `provider`, else the closest provider
* around it, else the first provider on the page. If the page has no
* provider, the element adds an empty one.
*/
export abstract class EmbedElement<
Config extends ScriptObject,
> extends LitElement {
/**
* The configuration of the content, the same as in a dashboard. Also read
* from a JSON or YAML script child, see `readScriptObject`.
*/
@property({ type: Object }) public config?: Config;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
protected _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// element and its content. Context requests only reach ancestors by
// themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const [origin] = ev.composedPath();
const node = origin instanceof HTMLElement ? origin : this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// The page's address belongs to the page, so only a hash on it, such as
// an anchor, navigates there. The browser does that itself, as the app's
// navigate() doesn't scroll to the anchor.
this.addEventListener("hass-navigate", (ev) => {
const { path, replace } = ev.detail;
ev.preventDefault();
if (!path.startsWith("#")) {
this.navigateTo(path, replace);
} else if (replace) {
window.location.replace(path);
} else {
window.location.hash = path;
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the element keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptObject(this);
if (data !== undefined) {
this.setScriptData(data);
}
}
/**
* A navigate action of the content, other than to a hash on the page. It
* leaves the page where it is, like actions that open a dialog. `replace`
* is the action's `navigation_replace`.
*/
protected navigateTo(_path: string, _replace?: boolean) {
// Nothing to navigate to, outside a dashboard
}
/** Whether the data of a script child is a configuration of the content. */
protected abstract isConfig(data: ScriptObject): data is Config;
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
protected setScriptData(data: ScriptObject) {
if (this.isConfig(data)) {
this.config = data;
}
}
/** The content, or `undefined` while there is nothing to render. */
protected abstract renderContent(
hass: HomeAssistant
): TemplateResult | undefined;
// The content in the theme of the provider
protected render() {
const hass = this._hass;
const content = hass && this.renderContent(hass);
if (!hass || !content) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
dir=${computeRTLDirection(hass)}
>
${content}
</div>`;
}
}
-28
View File
@@ -1,28 +0,0 @@
import "./public-path";
import "./workers";
// The provider must be defined first. Custom elements upgrade in the order
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
import "../../../src/components/item/ha-list-item-button";
import "../../../src/components/ha-select";
import { fontStyles } from "../../../src/resources/roboto";
// Font faces only work in the document, not in the shadow root of the card.
// Defining Roboto does not change the page unless it already uses Roboto.
document.head.append(
Object.assign(document.createElement("style"), {
textContent: fontStyles.cssText,
})
);
// Custom cards use the helpers to create built-in cards. The dashboard panel
// sets the same global in the full app.
(window as any).loadCardHelpers = () =>
import("../../../src/panels/lovelace/custom-card-helpers");
-99
View File
@@ -1,99 +0,0 @@
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
private _card = createRef<HuiCard>();
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
}
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
return html`<hui-card
${ref(this._card)}
.hass=${hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>`;
}
protected updated() {
// The card element only exists once hui-card has updated
this._card.value?.updateComplete.then(() => this._updateSize());
}
// The size the card gets in a section, set on the host so that the page can
// lay out cards in a grid of 12 columns, like a section
private _updateSize = () => {
const card = this._card.value;
if (!card) {
return;
}
const { rows, columns } = computeCardGridSize(card.getGridOptions());
this.hidden = card.hidden;
this.toggleAttribute("full-width", columns === "full");
this.toggleAttribute("fit-rows", typeof rows === "number");
this.style.setProperty(
"--column-size",
typeof columns === "number" ? String(columns) : null
);
this.style.setProperty(
"--row-size",
typeof rows === "number" ? String(rows) : null
);
};
static styles = [
embedElementStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
--row-gap: var(--ha-section-grid-row-gap, 8px);
--row-height: var(--ha-section-grid-row-height, 56px);
display: block;
grid-row: span var(--row-size, 1);
grid-column: span min(var(--column-size, 12), 12);
}
:host([full-width]) {
grid-column: 1 / -1;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
--row-gap
)
);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
-173
View File
@@ -1,173 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/hui-root";
import type { HomeAssistant } from "../../../src/types";
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
* view in `view`, so switching tabs and following links to its views don't
* change the page's URL. The dashboard scrolls inside the element, under its
* toolbar.
*/
@customElement("ha-embed-dashboard")
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
private _scroller = createRef<HTMLDivElement>();
// The route of hui-root, here without a page URL around it. A new
// configuration gives a new route, as hui-root only looks up the view of
// the path again when its route changes, and the views may have moved.
private _route = memoizeOne(
(view: string | undefined, _config: LovelaceConfig) => ({
prefix: "",
path: `/${view ?? ""}`,
})
);
protected get rawConfig() {
return this.config;
}
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
return isDashboardConfig(data);
}
// The view shown, and the views before it, which the back button of a
// subview without a `back_path` goes back through, as the browser's back
// would in the app
private _shownView?: string;
private _history: (string | undefined)[] = [];
// Going back, or a navigate action with `navigation_replace`, which
// replaces the view shown rather than adding to the views before it
private _skipHistory = false;
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this.view === this._shownView) {
return;
}
if (this._shownView !== undefined) {
if (!this._skipHistory) {
this._history.push(this._shownView);
}
this._scroller.value?.scrollTo({ top: 0 });
}
this._skipHistory = false;
this._shownView = this.view;
}
protected navigateTo(path: string, replace?: boolean) {
const view = this.viewOfPath(path);
if (view === undefined || this.isSameView(view, this.view)) {
return;
}
this._skipHistory = Boolean(replace);
this.view = view;
}
// The embedded attribute is in the template, so that hui-root has it when
// it connects and skips the listeners on the page
protected renderContent(hass: HomeAssistant) {
if (!this._lovelace?.config.views.length) {
return undefined;
}
return html`<div class="scroller" ${ref(this._scroller)}>
<hui-root
embedded
no-edit
.hass=${hass}
.lovelace=${this._lovelace}
.route=${this._route(this.view, this._lovelace.config)}
.narrow=${this._narrow.value ?? false}
@view-navigate=${this._viewNavigate}
></hui-root>
</div>`;
}
// A tab, or the back button of a subview, which goes to its `back_path`
// when that is one of the dashboard's views, else to the view before it
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
const view = this.viewOfPath(ev.detail.path);
if (!ev.detail.back) {
if (view !== undefined) {
this.view = view;
}
return;
}
const target = view === undefined ? this._previousView() : view;
// Following a `back_path` goes back too, so the view it goes to leaves
// the views before
if (
view !== undefined &&
this.isSameView(this._history[this._history.length - 1], view)
) {
this._history.pop();
}
if (!this.isSameView(target, this.view)) {
this._skipHistory = true;
this.view = target;
}
}
// The last view before that isn't the one shown, else the first view
private _previousView(): string | undefined {
while (this._history.length) {
const previous = this._history.pop();
if (!this.isSameView(previous, this.view)) {
return previous;
}
}
return undefined;
}
static styles = [
EmbedLovelaceElement.styles,
css`
/* Contained, so that the toolbar and background of hui-root, which are
fixed to the window in the app, are fixed to the element */
:host {
height: 600px;
contain: layout paint;
}
.theme,
.scroller {
height: 100%;
}
.scroller {
overflow: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-dashboard": HaEmbedDashboard;
}
}
-317
View File
@@ -1,317 +0,0 @@
import { ContextProvider, provide } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { provideHass } from "../../../src/fake_data/provide_hass";
import type { DemoArea } from "../stubs/area_registry";
import { toAreaRegistryEntry } from "../stubs/area_registry";
import { mockAuth } from "../stubs/auth";
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
import { mockLogbook } from "../stubs/logbook";
import { mockMediaPlayer } from "../stubs/media_player";
import { mockRecorder } from "../stubs/recorder";
import { mockSensor } from "../stubs/sensor";
import { mockExtractFromTarget } from "../stubs/target";
import { mockTemplate } from "../stubs/template";
import { mockTodo } from "../stubs/todo";
import { mockWeather } from "../stubs/weather";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
/** The data of a provider, as set by its script child. */
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
devices?: DemoDevice[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
// "living_room" becomes "Living room"
const areaName = (areaId: string) => {
const name = areaId.replaceAll("_", " ");
return name.charAt(0).toUpperCase() + name.slice(1);
};
/**
* Holds a mocked Home Assistant backend for all embed elements inside it.
* Elements in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@provide({ context: embedHassContext })
@property({ attribute: false })
public hass!: MockHomeAssistant;
/**
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `devices`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
@property({ attribute: false }) public areas?: DemoArea[];
/** All devices, which entities reference with their `device_id`. */
@property({ attribute: false }) public devices?: DemoDevice[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
*/
@property({ attribute: false }) public calendars?: Record<
string,
CalendarEventApiData[]
>;
/** Energy preferences. Without them, energy is not set up. */
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
/** Light or dark mode of the cards. Without it, the browser setting. */
@property({ attribute: "color-scheme" }) public colorScheme?:
"light" | "dark";
private _reducedMotion = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
private _unsubMediaQueries: (() => void)[] = [];
private _scriptRead = false;
private _applyColorScheme() {
const dark = this.colorScheme
? this.colorScheme === "dark"
: this._prefersDark;
if (this.hass.themes.darkMode !== dark) {
this.hass.updateHass({
themes: { ...this.hass.themes, darkMode: dark },
selectedTheme: { ...this.hass.selectedTheme!, dark },
});
}
}
constructor() {
super();
// Set up in the constructor so that the contexts exist before the
// child cards connect and request them.
const hass = provideHass(this);
hass.updateTranslations(null);
hass.updateTranslations("lovelace");
mockIcons(hass);
mockAuth(hass);
mockHistory(hass);
mockRecorder(hass);
mockTemplate(hass);
mockSensor(hass);
mockMediaPlayer(hass);
mockTodo(hass);
mockWeather(hass);
mockLogbook(hass);
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "logbook"],
},
});
mockCalendarEvents(hass, (entityId, currentHass) => {
if (!(entityId in currentHass.states)) {
return null;
}
const calendarIds = Object.keys(currentHass.states).filter((id) =>
id.startsWith("calendar.")
);
return (
this.calendars?.[entityId] ??
SAMPLE_CALENDAR_EVENTS[
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
]
);
});
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
...newEntry({
entity_id: entity.entity_id,
platform: entity.platform ?? "demo",
unique_id: entity.entity_id,
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
registryEntries(currentHass)
);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => {
const entry = registryEntries(currentHass).find(
(item) => item.entity_id === msg.entity_id
);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
mockExtractFromTarget(hass);
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
...EMPTY_PREFERENCES,
...this.energy,
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
this._applyColorScheme();
}
public connectedCallback() {
super.connectedCallback();
this._unsubMediaQueries = [
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
this._prefersDark = matches;
this._applyColorScheme();
}),
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
this._reducedMotion.setValue(matches)
),
];
// Only once, so that moving the provider keeps the current entities
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptData(this) as
EntityInput[] | EmbedProviderData | undefined;
if (Array.isArray(data)) {
this.entities = data;
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.devices = data.devices;
this.calendars = data.calendars;
this.energy = data.energy;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQueries.forEach((unsub) => unsub());
this._unsubMediaQueries = [];
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("colorScheme")) {
this._applyColorScheme();
}
if (
changedProps.has("entities") ||
changedProps.has("areas") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
const areas: DemoArea[] =
this.areas ??
[
...new Set(
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
const devicesById: Record<string, DeviceRegistryEntry> = {};
devices.forEach((device) => {
devicesById[device.id] = toDeviceRegistryEntry(device);
});
// Before the entities, so that their names can include the area and
// device
this.hass.updateHass({ areas: areasById, devices: devicesById });
// Entities that services changed keep their state when only the areas
// or devices change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
this.hass.updateFormatFunctions();
}
}
}
protected updated(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass");
// The first update holds the initial entities. They are not changes.
if (!oldHass) {
return;
}
// Other updates, such as loaded translations, keep the same states object.
if (oldHass.states !== this.hass.states) {
fireEvent(this, "states-changed", { states: this.hass.states });
}
}
protected render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-provider": HaEmbedProvider;
}
interface HASSDomEvents {
"states-changed": { states: HassEntities };
}
}
-95
View File
@@ -1,95 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
import "../../../src/panels/lovelace/sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../../../src/panels/lovelace/sections/section-container";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a dashboard section, or a section strategy, with the backend of a
* provider, see `EmbedElement`. The section takes the width of the element.
*/
@customElement("ha-embed-section")
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
// A title becomes a heading card, as on a dashboard
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
// A section has cards, sections inside it, a strategy that makes them, a
// title, or the type of a custom section
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
return (
"cards" in data ||
"sections" in data ||
"strategy" in data ||
"title" in data ||
("type" in data && typeof data.type === "string")
);
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// The section that hid the element is gone, and the next one starts
// visible without telling it
if (changedProps.has("config") && !this.config) {
this.hidden = false;
}
}
// In the container that draws the background of the section, as in a
// sections view
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
const config = this._normalizedConfig(this.config);
return renderSectionContainer(
hass,
config,
html`<hui-section
.hass=${hass}
.config=${config}
@section-visibility-changed=${this._visibilityChanged}
></hui-section>`
);
}
private _visibilityChanged(
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
) {
// Sections inside the section fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this.hidden = !ev.detail.value;
}
static styles = [
embedElementStyles,
sectionContainerStyles,
css`
:host {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-section": HaEmbedSection;
}
}
-134
View File
@@ -1,134 +0,0 @@
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import "../../../src/panels/lovelace/views/hui-view";
import "../../../src/panels/lovelace/views/hui-view-background";
import "../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../src/types";
import {
EmbedLovelaceElement,
findViewIndex,
isDashboardConfig,
} from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard view, a view strategy, or one view of a dashboard or
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
* `config` holds the view.
*/
@customElement("ha-embed-view")
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
/**
* A dashboard configuration or dashboard strategy, to show its `view`
* instead of `config`. Also read from the `dashboard` key of a script child,
* next to `view`.
*/
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
private _viewDashboard = memoizeOne(
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
);
protected get rawConfig() {
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
}
// A view strategy and a dashboard strategy both have a `strategy` key, so
// a dashboard comes in its own key, which no view has
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
return !("dashboard" in data);
}
protected setScriptData(data: ScriptObject) {
if (!("dashboard" in data)) {
super.setScriptData(data);
return;
}
const { dashboard } = data;
if (
typeof dashboard === "object" &&
dashboard !== null &&
isDashboardConfig(dashboard)
) {
this.dashboard = dashboard;
}
if (
"view" in data &&
(typeof data.view === "string" || typeof data.view === "number")
) {
this.view ??= String(data.view);
}
}
// Nothing for a dashboard without views, such as an empty strategy result.
// The view's theme and background, or the dashboard's background, as in
// hui-root. Each view gets its own hui-view, as there, so that a view
// strategy still generating for the view before can't replace the cards of
// this one.
protected renderContent(hass: HomeAssistant) {
const views = this._lovelace?.config.views;
if (!this._lovelace || !views?.length) {
return undefined;
}
const index = findViewIndex(views, this.view);
const viewConfig = views[index];
const background =
viewConfig.background || this._lovelace.config.background;
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
<hui-view-background .hass=${hass} .background=${background}>
</hui-view-background>
${keyed(
viewConfig,
html`<hui-view
.hass=${hass}
.lovelace=${this._lovelace}
.index=${index}
.narrow=${this._narrow.value ?? false}
></hui-view>`
)}
</hui-view-container>`;
}
// A fixed background stays inside the element. With a height set on the
// element, a panel view fills it, and other views keep their own height.
static styles = [
EmbedLovelaceElement.styles,
css`
:host {
contain: layout paint;
}
.theme,
hui-view-container,
hui-view {
display: block;
height: 100%;
}
/* The background sits below the view, above the element's own */
hui-view-container {
position: relative;
isolation: isolate;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-view": HaEmbedView;
}
}
-217
View File
@@ -1,217 +0,0 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { property, state } from "lit/decorators";
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import {
checkStrategyShouldRegenerate,
generateLovelaceDashboardStrategy,
} from "../../../src/panels/lovelace/strategies/get-strategy";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
// The width below which the app is narrow, see home-assistant-main
const NARROW_WIDTH = 870;
/** The index of the view with this path or index, if there is one. */
const viewIndex = (
views: LovelaceViewRawConfig[],
view: string
): number | undefined => {
const byPath = views.findIndex((item) => item.path === view);
if (byPath !== -1) {
return byPath;
}
const index = Number(view);
return Number.isInteger(index) && index >= 0 && index < views.length
? index
: undefined;
};
/** Whether script data is a dashboard: its views, or a strategy for them. */
export const isDashboardConfig = (
data: ScriptObject
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
/** The index of the view with this path or index, else the first view. */
export const findViewIndex = (
views: LovelaceViewRawConfig[],
view?: string
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
/**
* The base of the embed elements that render a dashboard, or one of its
* views. It generates dashboard strategies, again when the registries they
* depend on change, and holds a dashboard that can't be edited or saved.
*/
export abstract class EmbedLovelaceElement<
Config extends ScriptObject,
> extends EmbedElement<Config> {
/** The path or index of the view to show. Without it, the first view. */
@property() public view?: string;
@state() protected _lovelace?: Lovelace;
// Narrow like the app at the same width, measured on the element because
// it rarely takes the width of the page
protected _narrow = new ResizeController(this, {
callback: (entries) =>
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
});
// The configuration of the dashboard in `_lovelace`, or being generated
private _rawConfig?: LovelaceRawConfig;
// Counts the generations started, so that only the latest one is shown.
// Generating again for changed registries keeps the same configuration.
private _generation = 0;
// The backend at the last update, to tell what a strategy depends on changed
private _previousHass?: HomeAssistant;
/** The dashboard to render. The same object until it changes. */
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
/**
* The view of the dashboard a path goes to, relative like `batteries` or
* with the dashboard like `/maintenance/batteries`, if it has that view.
*/
protected viewOfPath(path: string | undefined): string | undefined {
const views = this._lovelace?.config.views;
const view = path
? decodeURI(new URL(path, "http://embed/").pathname)
.split("/")
.filter(Boolean)
.pop()
: undefined;
return views && view !== undefined && viewIndex(views, view) !== undefined
? view
: undefined;
}
/** Whether two paths or indexes go to the same view of the dashboard. */
protected isSameView(a?: string, b?: string): boolean {
const views = this._lovelace?.config.views;
return views
? findViewIndex(views, a) === findViewIndex(views, b)
: a === b;
}
// A path to one of the dashboard's views shows that view. Other paths leave
// the dashboard where it is.
protected navigateTo(path: string) {
const view = this.viewOfPath(path);
if (view !== undefined) {
this.view = view;
}
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
const raw = this.rawConfig;
const oldHass = this._previousHass;
this._previousHass = this._hass;
// Without a configuration, nothing is shown, and a generation still
// running is dropped
if (!raw) {
if (this._rawConfig) {
this._rawConfig = undefined;
this._lovelace = undefined;
this._generation++;
}
return;
}
if (!this._hass) {
return;
}
// A strategy is only asked whether to generate again when the backend
// changed, as in ha-panel-lovelace, not when the generated dashboard
// arrives
if (
raw !== this._rawConfig ||
(oldHass &&
oldHass !== this._hass &&
isStrategyDashboard(raw) &&
this._hass.config.state === "RUNNING" &&
checkStrategyShouldRegenerate(
"dashboard",
raw.strategy,
oldHass,
this._hass
))
) {
this._rawConfig = raw;
this._generate(raw, this._hass, ++this._generation);
}
}
private async _generate(
raw: LovelaceRawConfig,
hass: HomeAssistant,
generation: number
) {
const generated = isStrategyDashboard(raw)
? await generateLovelaceDashboardStrategy(raw, hass)
: raw;
const config = normalizeLovelaceConfig(generated);
// A newer generation has started. A generated dashboard has its views,
// rather than a strategy.
if (generation !== this._generation || isStrategyDashboard(config)) {
return;
}
this._lovelace = {
config,
rawConfig: raw,
editMode: false,
urlPath: null,
mode: "generated",
locale: hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
embedded: true,
};
}
// The background of a dashboard, and no insets for a notch or home bar
static styles: CSSResultGroup = [
embedElementStyles,
css`
:host {
display: block;
--app-safe-area-inset-top: 0px;
--app-safe-area-inset-bottom: 0px;
--app-safe-area-inset-left: 0px;
--app-safe-area-inset-right: 0px;
}
.theme {
position: relative;
background: var(--primary-background-color);
}
`,
];
}
-20
View File
@@ -1,20 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
var __HA_STATIC_PATH__: string | undefined;
}
declare let __webpack_public_path__: string;
/* eslint-enable @typescript-eslint/naming-convention */
// Load chunks and static files from the server that hosts this script, so
// that other sites can embed the cards. Only the modern production build keeps
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
// builds only run on the demo site, where the default paths are correct.
// `new URL(path, import.meta.url)` in one expression is an asset reference for
// the bundler, so the script URL goes into a variable first.
if (__BUILD__ === "modern" && !__DEV__) {
const scriptUrl = import.meta.url;
__webpack_public_path__ = new URL(".", scriptUrl).href;
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
}
export {};
-31
View File
@@ -1,31 +0,0 @@
import { load } from "js-yaml";
/**
* Parses the data in a `<script type="application/json">` or
* `<script type="text/yaml">` child of the element.
*/
export const readScriptData = (element: HTMLElement): unknown => {
const script = element.querySelector<HTMLScriptElement>(
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
);
if (!script) {
return undefined;
}
return script.type === "text/yaml"
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
-95
View File
@@ -1,95 +0,0 @@
import { css, unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
import { themeStyles } from "../../../src/resources/theme/theme";
// The theme styles set variables on `html`, next to fonts and other page
// styles. Only the variables, media queries, and keyframes are copied, and
// `html` becomes `selector`. This keeps the theme inside the card, so that it
// does not change the page that embeds the card.
const scopeThemeStyles = (cssText: string, selector: string): string => {
// A style element in a separate document parses the CSS without applying it.
const doc = document.implementation.createHTMLDocument("");
const style = doc.createElement("style");
style.textContent = cssText;
doc.head.append(style);
const convert = (rules: CSSRuleList): string =>
Array.from(rules, (rule) => {
if (rule instanceof CSSStyleRule) {
if (rule.selectorText !== "html") {
return "";
}
const variables = Array.from(rule.style)
.filter((name) => name.startsWith("--"))
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
return `${selector}{${variables.join("")}}`;
}
if (rule instanceof CSSMediaRule) {
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
}
if (rule instanceof CSSKeyframesRule) {
return rule.cssText;
}
return "";
}).join("");
return convert(style.sheet!.cssRules);
};
const darkTheme = {
...darkSemanticVariables,
...darkColorVariables,
};
// Like processTheme in apply_themes_on_element, each hex color also sets its
// `--rgb-` variable, else those keep their light theme values
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
const variable = `--${name}:${value};`;
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
return [variable];
}
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
});
export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
/**
* The styles of every embed element: the theme, and the page styles of the
* app, for the `.theme` wrapper of `EmbedElement`.
*/
export const embedElementStyles = [
embedThemeStyles,
css`
:host([hidden]) {
display: none;
}
/* The text styles the app sets on html and body, so that the content does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the content */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
-32
View File
@@ -1,32 +0,0 @@
// Browsers only start a worker from a script on the page's own origin, and
// the workers of the cards, such as the markdown worker, are served next to
// this script. A worker from another origin starts from a blob instead, which
// loads the real worker script. The demo serves its scripts with CORS.
const pageWorker = globalThis.Worker;
globalThis.Worker = class extends pageWorker {
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
const url = new URL(scriptUrl, location.href);
if (url.origin === location.origin) {
super(scriptUrl, options);
return;
}
const source =
options?.type === "module"
? `import ${JSON.stringify(url.href)};`
: `importScripts(${JSON.stringify(url.href)});`;
const blobUrl = URL.createObjectURL(
new Blob([source], { type: "text/javascript" })
);
try {
super(blobUrl, options);
} finally {
URL.revokeObjectURL(blobUrl);
}
}
};
export {};
-5
View File
@@ -36,7 +36,6 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockLogbook } from "./stubs/logbook";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -51,7 +50,6 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import { mockWeather } from "./stubs/weather";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -77,7 +75,6 @@ const CONFIG_PANEL_COMMANDS = [
"get_conditions_for_target",
"get_services_for_target",
"extract_from_target",
"explain_target",
"script/config",
"config/automation/config",
"config/script/config",
@@ -193,9 +190,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
import "<%= latestEntryJS[0] %>";
+13 -15
View File
@@ -12,20 +12,6 @@ export interface DemoArea {
let areas: AreaRegistryEntry[] = [];
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
});
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
@@ -39,7 +25,19 @@ export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map(toAreaRegistryEntry);
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+120 -10
View File
@@ -2,14 +2,14 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
import { explainTarget } from "./explain_target";
import { expandTarget, mockExtractFromTarget } from "./target";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
@@ -20,6 +20,109 @@ interface EntityFilter {
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
@@ -125,20 +228,27 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
mockExtractFromTarget(hass);
hass.mockWS(
"explain_target",
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
) =>
explainTarget(
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
(target, primaryEntitiesOnly) =>
expandTarget(currentHass, target, primaryEntitiesOnly),
Object.keys(currentHass.states),
msg.primary_entities_only ?? true
)
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
hass.mockWS(
"get_services_for_target",
-122
View File
@@ -1,122 +0,0 @@
import type { CalendarEventApiData } from "../../../src/data/calendar";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample events relative to today, so demos always show something in the
// current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
export const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
export const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const toTime = (value: CalendarEventApiData["start"]) =>
new Date(
typeof value === "string"
? value
: "dateTime" in value
? value.dateTime
: value.date
).getTime();
const inRange = (
events: CalendarEventApiData[],
start: string | null,
end: string | null
): CalendarEventApiData[] => {
const startTime = start ? new Date(start).getTime() : -Infinity;
const endTime = end ? new Date(end).getTime() : Infinity;
return events.filter(
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
);
};
/**
* Answers event requests of calendar entities with the events of
* `getEvents`. A calendar without events gets `null`, like a calendar whose
* integration fails to return them.
*/
export const mockCalendarEvents = (
hass: MockHomeAssistant,
getEvents: (
entityId: string,
currentHass: MockHomeAssistant
) => CalendarEventApiData[] | null | undefined
) => {
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
const events = getEvents(msg.entity_id, currentHass);
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
return () => undefined;
});
// Home Assistant before 2026.5 has no event subscription, so cards that
// support it ask the REST API instead.
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
const url = new URL(path, location.origin);
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
const events = getEvents(entityId, currentHass);
if (!events) {
return Promise.reject(new Error("Calendar not found"));
}
return inRange(
events,
url.searchParams.get("start"),
url.searchParams.get("end")
);
});
};
-54
View File
@@ -1,60 +1,6 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/** The fields of a device that the demo devices leave at their default. */
export const baseDevice: Omit<
DeviceRegistryEntry,
| "id"
| "name"
| "manufacturer"
| "model"
| "config_entries"
| "primary_config_entry"
| "entry_type"
> = {
config_entries_subentries: {},
connections: [],
identifiers: [],
model_id: null,
labels: [],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export interface DemoDevice {
id: string;
name: string;
manufacturer?: string;
model?: string;
area_id?: string;
via_device_id?: string;
entry_type?: "service";
}
export const toDeviceRegistryEntry = (
device: DemoDevice
): DeviceRegistryEntry => ({
...baseDevice,
id: device.id,
name: device.name,
manufacturer: device.manufacturer ?? null,
model: device.model ?? null,
area_id: device.area_id ?? null,
via_device_id: device.via_device_id ?? null,
entry_type: device.entry_type ?? null,
config_entries: [],
primary_config_entry: null,
});
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
+19 -1
View File
@@ -1,6 +1,24 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
import { baseDevice } from "./device_registry";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export const demoDevices: DeviceRegistryEntry[] = [
{
+20 -20
View File
@@ -11,7 +11,7 @@ export const energyEntities = () =>
entity_id: "sensor.solar_production",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Solar",
unit_of_measurement: "kWh",
},
@@ -20,7 +20,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_input",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Input",
unit_of_measurement: "kWh",
},
@@ -29,7 +29,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_output",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Output",
unit_of_measurement: "kWh",
},
@@ -38,7 +38,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Grid consumption low tariff",
unit_of_measurement: "kWh",
},
@@ -47,7 +47,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Grid consumption high tariff",
unit_of_measurement: "kWh",
},
@@ -56,7 +56,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Returned to grid low tariff",
unit_of_measurement: "kWh",
},
@@ -65,7 +65,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Returned to grid high tariff",
unit_of_measurement: "kWh",
},
@@ -74,7 +74,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -82,7 +82,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -90,7 +90,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -98,7 +98,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -138,7 +138,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -146,7 +146,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
@@ -155,7 +155,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Water",
unit_of_measurement: "L",
},
@@ -164,7 +164,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_car",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Electric car",
unit_of_measurement: "kWh",
},
@@ -173,7 +173,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_ac",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Air conditioning",
unit_of_measurement: "kWh",
},
@@ -182,7 +182,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_washing_machine",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Washing machine",
unit_of_measurement: "kWh",
},
@@ -191,7 +191,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_dryer",
state: "5.5",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Dryer",
unit_of_measurement: "kWh",
},
@@ -200,7 +200,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_heat_pump",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Heat pump",
unit_of_measurement: "kWh",
},
@@ -209,7 +209,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_boiler",
state: "7",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Boiler",
unit_of_measurement: "kWh",
},
+2 -14
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
export const newEntry = (
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
@@ -43,9 +43,7 @@ const displayFields = (entry: EntityRegistryEntry) => ({
hidden: entry.hidden_by !== null,
});
export const extEntry = (
entry: EntityRegistryEntry
): ExtEntityRegistryEntry => ({
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
@@ -58,16 +56,6 @@ export const mockEntityRegistry = (
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
const entry = entries.find((e) => e.entity_id === msg.entity_id);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
-79
View File
@@ -1,79 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type {
TargetExplanation,
HassServiceTargetWithExclude,
TargetItem,
TargetType,
} from "../../../src/data/target";
const TYPES: TargetType[] = ["entity", "device", "area", "floor", "label"];
const items = (target?: HassServiceTarget): TargetItem[] =>
TYPES.flatMap((type) => {
const ids = target?.[`${type}_id`];
return ids ? ensureArray(ids).map((id) => ({ type, id: String(id) })) : [];
});
/** What one target item resolves to, and the areas and devices it reaches. */
export interface ResolvedTargetItem {
areas: string[];
devices: string[];
entities: string[];
}
/**
* Like core's explain_target: resolves each item of the target and of its
* `exclude` on its own with `resolve`, and tells what each exclude takes out
* of each included item. Like core, `entity_id: all` is every entity in
* `entityIds`, and excludes resolve every entity, not only the primary ones.
*/
export const explainTarget = (
target: HassServiceTargetWithExclude,
resolve: (
target: HassServiceTarget,
primaryEntitiesOnly: boolean
) => ResolvedTargetItem,
entityIds: string[],
primaryEntitiesOnly = true
): TargetExplanation => {
const single = ({ type, id }: TargetItem): HassServiceTarget => ({
[`${type}_id`]: [id],
});
const includes = items(target).map((item) => {
if (item.type === "entity" && item.id === "all") {
return {
...item,
entities: entityIds,
referenced_areas: [],
referenced_devices: [],
};
}
const { areas, devices, entities } = resolve(
single(item),
primaryEntitiesOnly
);
return {
...item,
entities,
referenced_areas: areas,
referenced_devices: devices,
};
});
return {
includes,
excludes: items(target.exclude).map((item) => {
const excluded = new Set(resolve(single(item), false).entities);
return {
...item,
removed: includes
.map(({ type, id, entities }) => ({
type,
id,
entities: entities.filter((entityId) => excluded.has(entityId)),
}))
.filter((include) => include.entities.length > 0),
};
}),
};
};
-15
View File
@@ -1,15 +0,0 @@
import type { LogbookStreamMessage } from "../../../src/data/logbook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/**
* Answers logbook subscriptions with an empty logbook. No entries come from
* an automation or script trace.
*/
export const mockLogbook = (hass: MockHomeAssistant) => {
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] } satisfies LogbookStreamMessage);
return () => undefined;
});
hass.mockWS("trace/contexts", () => ({}));
};
-1
View File
@@ -149,7 +149,6 @@ const repositories: RepositoryBase[] = [
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
beta: false,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
+1 -3
View File
@@ -372,9 +372,7 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState &&
(entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
entityState && "last_reset" in entityState.attributes
? generateSumStatistics(
start,
end,
-132
View File
@@ -1,132 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
// Expands a target to its areas, devices and entities, like core does
export const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
-133
View File
@@ -1,133 +0,0 @@
import type {
ForecastAttribute,
ModernForecastType,
} from "../../../src/data/weather";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600000;
const CONDITIONS = [
"partlycloudy",
"rainy",
"cloudy",
"sunny",
"pouring",
"partlycloudy",
"sunny",
];
// Daily changes around the current temperature, so the forecast looks real.
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
const PRECIPITATION = new Map([
["rainy", 2.4],
["pouring", 9.1],
]);
const forecastEntry = (
datetime: Date,
temperature: number,
condition: string
): ForecastAttribute => ({
datetime: datetime.toISOString(),
condition,
temperature: Math.round(temperature * 10) / 10,
precipitation: PRECIPITATION.get(condition) ?? 0,
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
});
const generateForecast = (
forecastType: ModernForecastType,
temperature: number,
condition: string
): ForecastAttribute[] => {
const conditions = [condition, ...CONDITIONS.slice(1)];
const now = new Date();
if (forecastType === "hourly") {
now.setMinutes(0, 0, 0);
return Array.from({ length: 24 }, (_, hour) => {
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
// Warmest mid-afternoon and coldest before dawn
const change =
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
return forecastEntry(
datetime,
temperature + change,
conditions[Math.floor(hour / 6)]
);
});
}
now.setHours(12, 0, 0, 0);
if (forecastType === "twice_daily") {
return conditions.flatMap((dayCondition, day) => {
const datetime = new Date(now.getTime() + day * 24 * HOUR);
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return [
{
...forecastEntry(datetime, dayTemperature, dayCondition),
is_daytime: true,
},
{
...forecastEntry(
new Date(datetime.getTime() + 12 * HOUR),
dayTemperature - 6,
dayCondition
),
is_daytime: false,
},
];
});
}
return conditions.map((dayCondition, day) => {
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return {
...forecastEntry(
new Date(now.getTime() + day * 24 * HOUR),
dayTemperature,
dayCondition
),
templow: Math.round((dayTemperature - 6) * 10) / 10,
};
});
};
/**
* Answers forecast subscriptions of weather entities. An entity with a
* `forecast` attribute gets that forecast. Other entities get a forecast made
* up from their condition and temperature.
*/
export const mockWeather = (hass: MockHomeAssistant) => {
hass.mockWS(
"weather/subscribe_forecast",
(
msg: { entity_id: string; forecast_type: ModernForecastType },
currentHass,
onChange
) => {
const stateObj = currentHass.states[msg.entity_id];
onChange?.({
type: msg.forecast_type,
forecast: stateObj
? (stateObj.attributes.forecast ??
generateForecast(
msg.forecast_type,
Number(stateObj.attributes.temperature ?? 15),
stateObj.state
))
: null,
});
return () => undefined;
}
);
};
-4
View File
@@ -81,10 +81,6 @@ export default [
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Charts",
pages: ["state-history-charts", "statistics-chart"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
+77 -10
View File
@@ -1,14 +1,68 @@
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../../../demo/src/stubs/calendar";
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
@@ -25,8 +79,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
start_time: at(0, 8),
end_time: at(0, 8, 30),
location: "",
description: "",
supported_features:
@@ -42,8 +96,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
start_time: at(0, 9),
end_time: at(0, 9, 15),
},
},
{
@@ -91,7 +145,20 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
+2 -7
View File
@@ -683,13 +683,8 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
name: "Device class",
selector: { device_class: { domain: "sensor" } },
},
sensor_state_class: {
name: "State class",
@@ -5,7 +5,6 @@ import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { explainTarget } from "../../../../demo/src/stubs/explain_target";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { ensureArray } from "../../../../src/common/array/ensure-array";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
@@ -226,20 +225,6 @@ class DemoHaTargetPickerExclusions
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
hass.mockWS("extract_from_target", extractFromTarget);
hass.mockWS("explain_target", ({ target }: { target: HassServiceTarget }) =>
explainTarget(
target,
(single) => {
const resolved = extractFromTarget({ target: single });
return {
areas: resolved.referenced_areas,
devices: resolved.referenced_devices,
entities: resolved.referenced_entities,
};
},
Object.keys(hass.states)
)
);
hass.mockWS("auth/sign_path", (params) => params);
}
@@ -1,6 +0,0 @@
---
title: History charts
subtitle: State history as timelines and line charts.
---
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
@@ -1,211 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/state-history-charts";
import "../../../../src/components/ha-card";
import type {
HistoryResult,
LineChartState,
TimelineState,
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const ENTITIES = [
{
entity_id: "sensor.outdoor_temperature",
state: "12.4",
attributes: {
friendly_name: "Outdoor temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
{
entity_id: "light.living_room",
state: "on",
attributes: { friendly_name: "Living room light" },
},
{
entity_id: "binary_sensor.front_door",
state: "off",
attributes: { friendly_name: "Front door", device_class: "door" },
},
];
const temperatureStates = (): LineChartState[] =>
Array.from({ length: HOURS * 2 }, (_, index) => {
const time = START.getTime() + (index * HOUR) / 2;
const hourOfDay = new Date(time).getHours();
const value =
10 +
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.5 * Math.sin(time / HOUR);
return {
state: value.toFixed(1),
last_changed: time,
};
});
const toggleStates = (
onHours: [number, number][],
onLabel: string,
offLabel: string
): TimelineState[] => {
const on = { state: "on", state_localize: onLabel };
const off = { state: "off", state_localize: offLabel };
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
onHours.forEach(([from, to]) => {
states.push(
{ ...on, last_changed: START.getTime() + from * HOUR },
{ ...off, last_changed: START.getTime() + to * HOUR }
);
});
return states;
};
const HISTORY: HistoryResult = {
line: [
{
unit: "°C",
device_class: "temperature",
identifier: "temperature",
data: [
{
domain: "sensor",
name: "Outdoor temperature",
entity_id: "sensor.outdoor_temperature",
states: temperatureStates(),
},
],
},
],
timeline: [
{
name: "Living room light",
entity_id: "light.living_room",
data: toggleStates(
[
[6, 8],
[18, 23],
[30, 32],
[42, 47],
],
"On",
"Off"
),
},
{
name: "Front door",
entity_id: "binary_sensor.front_door",
data: toggleStates(
[
[7.5, 7.6],
[17, 17.2],
[31.5, 31.6],
[41, 41.1],
],
"Open",
"Closed"
),
},
],
};
const LIGHT_ONLY: HistoryResult = {
line: [],
timeline: [HISTORY.timeline[0]],
};
interface Example {
title: string;
description: string;
history: HistoryResult;
showNames?: boolean;
insideLabels?: boolean;
syncCharts?: boolean;
}
const EXAMPLES: Example[] = [
{
title: "Names in a column",
description:
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
history: HISTORY,
},
{
title: "Names inside the timeline, synced zoom",
description:
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
history: HISTORY,
insideLabels: true,
syncCharts: true,
},
{
title: "Single entity without names",
description:
"Like the more-info dialog, where the entity is already known.",
history: LIGHT_ONLY,
showNames: false,
},
];
@customElement("demo-components-state-history-charts")
export class DemoStateHistoryCharts extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<state-history-charts
.historyData=${example.history}
.startTime=${START}
.endTime=${END}
.showNames=${example.showNames ?? true}
.insideLabels=${example.insideLabels ?? false}
.syncCharts=${example.syncCharts ?? false}
.clickForMoreInfo=${false}
></state-history-charts>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-state-history-charts": DemoStateHistoryCharts;
}
}
@@ -1,6 +0,0 @@
---
title: Statistics chart
subtitle: Long-term statistics as a bar or line chart.
---
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
@@ -1,212 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-card";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
StatisticValue,
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const round = (value: number, digits: number) =>
Math.round(value * 10 ** digits) / 10 ** digits;
const hourlyValues = (
compute: (start: number) => Partial<StatisticValue>
): StatisticValue[] =>
Array.from({ length: HOURS }, (_, hour) => {
const start = START.getTime() + hour * HOUR;
return { start, end: start + HOUR, ...compute(start) };
});
const energyValues = (dayUse: number, eveningUse: number) => {
let sum = 0;
return hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
sum += change;
return { change, sum: round(sum, 2) };
});
};
const temperatureValues = (average: number, swing: number) =>
hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const mean =
average +
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.3 * Math.sin(start / HOUR);
return {
mean: round(mean, 1),
min: round(mean - 0.4, 1),
max: round(mean + 0.4, 1),
};
});
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
const LIGHTING_ID = "sensor.lighting_energy";
const LIVING_ROOM_ID = "sensor.living_room_temperature";
const BEDROOM_ID = "sensor.bedroom_temperature";
const HEAT_PUMP = energyValues(0.4, 1.2);
const LIGHTING = energyValues(0.05, 0.3);
const LIVING_ROOM = temperatureValues(21, 1.5);
const BEDROOM = temperatureValues(18.5, 1);
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "kWh",
source: "recorder",
name,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
});
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "°C",
source: "recorder",
name,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
});
const METADATA: Record<string, StatisticsMetaData> = {
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
};
const NAMES = Object.fromEntries(
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
);
interface Example {
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
unit: string;
hideLegend?: boolean;
height?: string;
}
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
description:
"The change of a sum for each hour, like an energy statistics graph card.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
},
{
title: "Stacked bars",
description: "Several sums stacked, to compare what each one adds.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
statTypes: ["change"],
chartType: "bar-stack",
unit: "kWh",
},
{
title: "Mean, min and max",
description: "A measurement with its hourly mean, min and max.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
},
{
title: "Several statistics",
description: "The mean of several measurements with the same unit.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
statTypes: ["mean"],
chartType: "line",
unit: "°C",
},
{
title: "Without legend",
description:
"A single statistic with a fixed height, like in the more-info dialog.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
hideLegend: true,
height: "240px",
},
];
@customElement("demo-components-statistics-chart")
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.hideLegend=${example.hideLegend ?? false}
.height=${example.height}
.clickForMoreInfo=${false}
></statistics-chart>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-statistics-chart": DemoStatisticsChart;
}
}
-114
View File
@@ -201,50 +201,6 @@ const ENTITIES = [
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.on_off_fan",
state: "on",
attributes: {
friendly_name: "Bathroom fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.six_speed_fan",
state: "on",
attributes: {
friendly_name: "Kitchen fan",
device_class: "fan",
percentage: 50,
percentage_step: 100 / 6,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.ten_speed_fan",
state: "on",
attributes: {
friendly_name: "Living room fan",
device_class: "fan",
percentage: 70,
percentage_step: 10,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.eleven_speed_fan",
state: "on",
attributes: {
friendly_name: "Office fan",
device_class: "fan",
percentage: 45,
percentage_step: 100 / 11,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -454,55 +410,6 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds, slider style)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
config: {
type: "tile",
entity: "fan.on_off_fan",
features: [{ type: "fan-speed", style: "slider" }],
},
},
{
heading: "Fan speed feature (6 speeds, slider by default)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (6 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (10 speeds, buttons style)",
config: {
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading:
"Fan speed feature (11 speeds, buttons style falls back to slider)",
config: {
type: "tile",
entity: "fan.eleven_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
@@ -523,27 +430,6 @@ const CONFIGS = [
],
},
},
{
heading:
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.six_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
{
type: "tile",
entity: "fan.ten_speed_fan",
features: [{ type: "fan-speed", style: "buttons" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
+17 -17
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.1.0",
"@babel/runtime": "8.0.10",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -54,17 +54,17 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@formatjs/intl-datetimeformat": "7.8.2",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.15",
"@formatjs/intl-locale": "5.3.12",
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.16",
"@formatjs/intl-relativetimeformat": "12.3.16",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/common": "1.5.4",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -80,7 +80,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "8.0.0",
"@versatiles/style": "7.1.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -89,7 +89,7 @@
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.51.0",
"cropperjs": "1.7.0",
"cropperjs": "2.3.1",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
@@ -137,14 +137,14 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.1.0",
"@babel/core": "8.0.10",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.23.0",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
@@ -177,7 +177,7 @@
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.51.0",
"del": "9.0.1",
"eslint": "10.14.0",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -189,7 +189,7 @@
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "14.0.3",
"globals": "17.14.0",
"globals": "17.13.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
+4 -63
View File
@@ -1,8 +1,8 @@
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
--- a/modular/sortable.core.esm.js
+++ b/modular/sortable.core.esm.js
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
}
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
capture();
@@ -24,7 +24,7 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
}
parentEl = el; // actualization
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
targetRect = getRect(target);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
capture();
@@ -38,16 +38,7 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
parentEl = el; // actualization
changed();
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
pastFirstInvertThresh = false;
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
var sibling;
if (direction !== 0) {
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
_silent = true;
setTimeout(_unsilent, 30);
capture();
@@ -67,53 +58,3 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
}
// Undo chrome's scroll adjustment (has no effect on other browsers)
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
function _unsilent() {
_silent = false;
}
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
+// checks below keep assuming that the list runs from left to right.
+function _isRtl(el, vertical) {
+ return !vertical && css(el, 'direction') === 'rtl';
+}
+function _mirrorX(rect) {
+ return {
+ left: -rect.right,
+ right: -rect.left,
+ top: rect.top,
+ bottom: rect.bottom,
+ width: rect.width,
+ height: rect.height
+ };
+}
function _ghostIsFirst(evt, vertical, sortable) {
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ firstElRect = _mirrorX(firstElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
}
function _ghostIsLast(evt, vertical, sortable) {
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ lastElRect = _mirrorX(lastElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
}
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
+ if (rtl) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ targetRect = _mirrorX(targetRect);
+ }
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
targetLength = vertical ? targetRect.height : targetRect.width,
targetS1 = vertical ? targetRect.top : targetRect.left,
+676 -574
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
tslib: 2.9.1
tslib: 2.8.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
@@ -1,8 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 582 B

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

Before

Width:  |  Height:  |  Size: 584 B

+1 -18
View File
@@ -13,9 +13,6 @@
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["pnpmDedupe"],
"constraints": {
"ghActionsLock": "v0.4.1"
},
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
"enabled": true,
@@ -62,26 +59,12 @@
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
"customType": "regex",
"managerFilePatterns": [
"/^\\.github/workflows/actions-lock\\.yaml$/",
"/^renovate\\.json$/"
],
"matchStrings": [
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
],
"depNameTemplate": "github/gh-actions-lock",
"datasourceTemplate": "github-releases"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
export const URL_ATTRIBUTES = [
const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
-37
View File
@@ -20,11 +20,6 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -162,35 +157,3 @@ export const findEntities = (
return results;
};
export const excludeEntities = (
entityIds: string[],
excludedEntityIds?: string[]
): string[] => {
if (!excludedEntityIds?.length) {
return entityIds;
}
const excluded = new Set(excludedEntityIds);
return entityIds.filter((entityId) => !excluded.has(entityId));
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[],
excludedEntityIds?: string[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return excludeEntities(
findEntities(Object.keys(sources.states), filterFuncs),
excludedEntityIds
);
};
+6 -11
View File
@@ -16,8 +16,8 @@ import {
// other one is built in the browser. The attribution comes from the TileJSON,
// deliberately: it follows whoever serves the tiles.
const SHIPPED_STYLES = {
light: `${__STATIC_PATH__}map/light.json`,
dark: `${__STATIC_PATH__}map/dark.json`,
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
@@ -92,12 +92,8 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
// The origin that serves the frontend's files: the page, except for the demo
// embed on another site
const filesBase = () => new URL(__STATIC_PATH__, location.href);
// MapLibre rejects a relative sprite URL
const absoluteSprite = (url: string) => new URL(url, filesBase()).href;
const absoluteSprite = (url: string) => new URL(url, location.href).href;
// Shared only while in flight, or a style that failed once would never be
// fetched again. Text rather than a parsed style: MapLibre mutates what it
@@ -129,7 +125,7 @@ export const loadStyle = async (
).buildMapStyle(
mapStyle.palette,
mapStyle.options ?? {},
mapStyle.styleOptions
mapStyle.baseColors
);
if (typeof style.sprite === "string") {
@@ -145,15 +141,14 @@ export const loadStyle = async (
// Global to MapLibre; set once before the first map is created. Absolute,
// because on Cast the page is not served from the instance. The URL comes
// from the build: the worker is an entry of its own, next to the app. MapLibre
// starts a worker from another origin itself.
// from the build: the worker is an entry of its own, next to the app.
let workerUrlSet = false;
export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
if (workerUrlSet) {
return;
}
workerUrlSet = true;
setUrl(new URL(__MAPLIBRE_WORKER_URL__, filesBase()).href);
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
};
const createVectorLayer = async (
+9 -5
View File
@@ -16,7 +16,7 @@ import { finalizeMapStyle } from "./map-style-transforms";
*/
// Versioned: /static is served with a month of max-age, so a cached manifest
// could otherwise pair a new build with the sprite sheet of an old one.
const STYLE_URLS_PATH = `${__STATIC_PATH__}map/urls.json?v=${__VERSION__}`;
const STYLE_URLS_PATH = `/static/map/urls.json?v=${__VERSION__}`;
let urls: Promise<OsmOptions["urls"]> | undefined;
@@ -35,7 +35,7 @@ const styleUrls = () => {
export const buildMapStyle = async (
palette: MapPalette,
options: Record<string, unknown>,
styleOptions?: Record<string, unknown>
baseColors?: Record<string, string>
): Promise<StyleSpecification> => {
const [{ osm }, resolvedUrls] = await Promise.all([
import("@versatiles/style"),
@@ -50,9 +50,13 @@ export const buildMapStyle = async (
// The builder throws on an option it does not know and on a color it cannot
// parse. Those come from a card config or a theme, so one typo would
// otherwise drop the whole card to raster tiles. Step back to the style as it
// comes - colors and adjustments included, or the default would come out as
// the builder's unpainted cartography - and only then to the bare palette.
const attempts = [options, ...(styleOptions ? [styleOptions] : []), {}];
// ships - colors included, or the default would come out as the builder's
// unpainted cartography - and only then to the bare palette.
const attempts = [
options,
...(baseColors ? [{ colors: baseColors }] : []),
{},
];
let style: StyleSpecification | undefined;
for (const attempt of attempts) {
try {
@@ -78,7 +78,7 @@ const POSITIONS: Record<
bottomright: "bottom-right",
};
const MAPLIBRE_CSS_URL = `${__STATIC_PATH__}map/maplibre-gl.css`;
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
// Fully hidden behind the globe, not MapLibre's faint ghost
const MARKER_OPTIONS = { opacityWhenCovered: 0 };
+16 -74
View File
@@ -1,12 +1,11 @@
// The vector cartographies the map can draw. Each is a light and a dark
// palette; the theme mode picks the half, unless the style only has one.
// palette; the theme mode picks the half.
//
// Only the default pair is generated at build time (build-scripts/gulp/
// map-assets.js); everything else is built in the browser from the same
// builder, against the tiles and glyphs core's proxy already serves.
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
import { NEON_MAP_COLORS_DARK, NEON_MAP_OPTIONS } from "./neon-map-palette";
/** Style ids, in the order the card editor offers them */
export const MAP_STYLES = [
@@ -16,7 +15,6 @@ export const MAP_STYLES = [
"muted",
"gray",
"toner",
"neon",
] as const;
export type MapStyle = (typeof MAP_STYLES)[number];
@@ -28,19 +26,13 @@ type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
/**
* A cartography plus what is painted over it; the VersaTiles palettes have
* nothing. The color sets say which halves a style has: both, or neither, and
* the theme mode picks; only one, and that is the half the map draws.
*/
/** A cartography plus the colors painted over it; only the default has any */
const STYLE_PALETTES: Record<
MapStyle,
{
palette: VersatilesPalette;
colors?: Record<string, string>;
colorsDark?: Record<string, string>;
/** Builder options the style comes with, by option group */
options?: Record<string, Record<string, unknown>>;
}
> = {
default: {
@@ -48,11 +40,6 @@ const STYLE_PALETTES: Record<
colors: HA_MAP_COLORS,
colorsDark: HA_MAP_COLORS_DARK,
},
neon: {
palette: "colorful",
colorsDark: NEON_MAP_COLORS_DARK,
options: NEON_MAP_OPTIONS,
},
colorful: { palette: "colorful" },
natural: { palette: "natural" },
muted: { palette: "muted" },
@@ -109,12 +96,11 @@ export interface ResolvedMapStyle {
/** Fetched rather than built: the default, while nothing was added to it */
shipped?: "light" | "dark";
/**
* The style as it comes: its colors and its own adjustments, without what
* the theme or the card painted over them. What to fall back on when the
* builder rejects an adjustment, so a bad value costs the card's adjustments
* rather than the style.
* The style's own colors, without what the theme or the card painted over
* them. What to fall back on when the builder rejects an adjustment, so a
* bad value costs the adjustments rather than the cartography.
*/
styleOptions?: Record<string, unknown>;
baseColors?: Record<string, string>;
}
const isStyle = (value: unknown): value is MapStyle =>
@@ -124,31 +110,9 @@ export const isCustomMapStyle = (
style: MapStyleConfig | undefined
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
const toStyle = (style: MapStyleConfig | undefined): MapStyle => {
const named = isCustomMapStyle(style) ? style.base : style;
return isStyle(named) ? named : DEFAULT_MAP_STYLE;
};
/**
* Whether the map comes out dark: the theme mode's answer, unless the style
* only has the one half. What the map's own chrome and the theme tokens have
* to follow, rather than the theme mode.
*/
export const isDarkMapStyle = (
style: MapStyleConfig | undefined,
darkMode: boolean
): boolean => {
const { colors, colorsDark } = STYLE_PALETTES[toStyle(style)];
if (Boolean(colors) !== Boolean(colorsDark)) {
return Boolean(colorsDark);
}
return darkMode;
};
/** The half to draw; `dark` is the map's, from isDarkMapStyle */
export const paletteFor = (style: MapStyle, dark: boolean): MapPalette => {
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
const { palette } = STYLE_PALETTES[style];
return dark ? `${palette}-dark` : palette;
return darkMode ? `${palette}-dark` : palette;
};
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
@@ -234,33 +198,19 @@ export const resolveMapStyle = (
themeColors?: Record<string, string>
): ResolvedMapStyle => {
const custom = isCustomMapStyle(style) ? style : undefined;
const mapStyle = toStyle(style);
// A dark-only style picks the dark half of everything, not just the palette.
const dark = isDarkMapStyle(style, darkMode);
const named = custom ? custom.base : style;
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
const palette = paletteFor(mapStyle, dark);
const options = custom ? builderOptions(custom, dark) : {};
const palette = paletteFor(mapStyle, darkMode);
const options = custom ? builderOptions(custom, darkMode) : {};
// Before the colors below are merged in, so it answers whether the card
// asked for anything of its own.
const cardAdjusted = Object.keys(options).length > 0;
// The style's own adjustments go under the card's, group by group, so a
// card that sets one recolor value keeps the style's others.
const {
colors,
colorsDark,
options: styleOptions,
} = STYLE_PALETTES[mapStyle];
for (const [group, values] of Object.entries(styleOptions ?? {})) {
options[group] = {
...values,
...(options[group] as Record<string, unknown> | undefined),
};
}
// Bottom up: the style's colors, what the theme repaints, then what the card
// names - so a card that sets one color keeps the rest of the look.
const styleColors = dark ? colorsDark : colors;
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
if (styleColors || themeColors) {
options.colors = {
...styleColors,
@@ -273,19 +223,11 @@ export const resolveMapStyle = (
return { palette };
}
const own = styleColors || styleOptions;
const base = own
? {
styleOptions: {
...styleOptions,
...(styleColors && { colors: styleColors }),
},
}
: {};
const base = styleColors ? { baseColors: styleColors } : {};
// Anything added on top of the shipped default has to be built here.
const untouched = !themeColors && !cardAdjusted;
return mapStyle === DEFAULT_MAP_STYLE && untouched
? { palette, options, ...base, shipped: dark ? "dark" : "light" }
? { palette, options, ...base, shipped: darkMode ? "dark" : "light" }
: { palette, options, ...base };
};
-13
View File
@@ -1,13 +0,0 @@
import { isAndroidDevice } from "../../util/is_android";
import { isIosDevice } from "../../util/is_ios";
export const navigationUrl = (latitude: number, longitude: number): string => {
const point = `${latitude},${longitude}`;
if (isIosDevice()) {
return `https://maps.apple.com/?daddr=${point}`;
}
if (isAndroidDevice()) {
return `geo:${point}?q=${point}`;
}
return `https://www.openstreetmap.org/directions?to=${encodeURIComponent(point)}#map=15/${latitude}/${longitude}`;
};
-64
View File
@@ -1,64 +0,0 @@
// The "neon" style: the VersaTiles "colorful" cartography repainted as a night
// map, glowing roads on near-black ground. A dark set and no light one, which
// is what makes the style dark whatever the theme mode.
export const NEON_MAP_COLORS_DARK = {
background: "#07021a",
land: "#07021a",
water: "#13215e",
labelWater: "#00e5ff",
glacier: "#1b2a6b",
natureWood: "#0d1f2e",
natureGrass: "#101a33",
naturePark: "#12203a",
natureLeisure: "#111a30",
natureAgriculture: "#0d1528",
natureWetland: "#0e2440",
natureSand: "#1a1230",
natureRock: "#140c28",
areaResidential: "#0c0524",
areaCommercial: "#140430",
areaIndustrial: "#10062a",
areaWaste: "#160a2e",
areaBurial: "#0d0a26",
siteParking: "#0f0728",
siteSports: "#0a1c2c",
siteEducation: "#160a34",
siteHospital: "#2a0a2a",
siteDanger: "#ff1744",
sitePrison: "#1c0a28",
siteConstruction: "#3a1d5c",
building: "#160a33",
buildingBg: "#8a2be2",
roadStreet: "#00e5ff",
roadStreetBg: "#062b52",
roadSecondary: "#8f7bff",
roadSecondaryBg: "#241058",
roadTrunk: "#ff6ec7",
roadTrunkBg: "#4a0f38",
roadMotorway: "#ff2d95",
roadMotorwayBg: "#5c0033",
transitRail: "#7b2cff",
transitSubway: "#b14cff",
transitCycle: "#00ffa3",
transitFoot: "#2e2a55",
boundary: "#39ff14",
boundaryDisputed: "#1f7a0d",
label: "#ff9ad5",
labelHalo: "rgba(7,2,26,0.9)",
labelSymbol: "#ff9ad5",
labelPoi: "#00e5ff",
labelShield: "#12002a",
labelHousenumber: "rgba(0,229,255,0.35)",
};
/** Builder options the style comes with, under whatever a card adds */
export const NEON_MAP_OPTIONS = {
recolor: { saturate: 0.35, contrast: 1.15 },
text: {
transform: "uppercase",
letterSpacing: 0.12,
haloWidth: 2,
haloBlur: 0,
},
};
@@ -0,0 +1,14 @@
import type {
ListItem,
RequestSelectedDetail,
} from "@material/mwc-list/mwc-list-item";
export const shouldHandleRequestSelectedEvent = (
ev: CustomEvent<RequestSelectedDetail>
): boolean => {
if (!ev.detail.selected || ev.detail.source !== "property") {
return false;
}
(ev.currentTarget as ListItem).selected = false;
return true;
};
+2 -13
View File
@@ -16,13 +16,6 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -203,8 +196,7 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
}
@@ -223,10 +215,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
-3
View File
@@ -20,7 +20,6 @@ export const entitiesFilterQueryParamConfig = {
"floor",
"area",
"device",
"device_class",
"domain",
"entity_domain",
"label",
@@ -51,7 +50,6 @@ export const entitiesFilterQueryParams = (
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
device_class: list(filters["ha-filter-device-classes"]),
domain: list(filters["ha-filter-integrations"]),
entity_domain: list(filters["ha-filter-domains"]),
label: list(filters["ha-filter-labels"]),
@@ -116,7 +114,6 @@ export const entitiesLinkFromUrl = (
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
"ha-filter-devices": values("device"),
"ha-filter-device-classes": values("device_class"),
"ha-filter-integrations": values("domain"),
"ha-filter-domains": values("entity_domain"),
"ha-filter-labels": values("label"),
+1 -10
View File
@@ -1,4 +1,3 @@
import { SYSTEM_LOG_LEVELS, type SystemLogLevel } from "../../data/system_log";
import {
decodeQueryParams,
hasQueryParams,
@@ -18,7 +17,7 @@ export const logSearchQueryParamConfig = {
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration", "level"],
list: ["integration"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -29,19 +28,13 @@ export interface LogsState {
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
// Only apply to the Home Assistant Core errors, not the raw log
levels: SystemLogLevel[];
raw: boolean;
}
const isLevel = (level: string): level is SystemLogLevel =>
(SYSTEM_LOG_LEVELS as readonly string[]).includes(level);
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
levels: [],
raw: false,
};
@@ -60,7 +53,6 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
@@ -79,7 +71,6 @@ export const logsLinkFromUrl = (
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
raw: core && !!params.raw,
};
};
@@ -1,4 +1,4 @@
import { provide, type ContextType } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -17,14 +17,9 @@ import { getEntityTarget } from "../../panels/config/automation/target/get_entit
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
import { targetExpandGroupContext } from "../target-picker/target-expand-group-context";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
// Triggers and conditions don't expand the groups their targets name.
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@@ -67,88 +67,3 @@ export const itemTooltipPosition: TooltipPositionCallback = (
return [x, y];
};
const ECHARTS_TOOLTIP_GAP_PX = 20;
export const WINDOW_MARGIN_PX = 8;
const fitInto = (
pos: number,
size: number,
alternatives: number[],
min: number,
max: number
) =>
size > max - min
? min
: ([pos, ...alternatives].find((p) => p >= min && p + size <= max) ??
Math.max(min, Math.min(pos, max - size)));
/**
* ECharts' default placement (or the chart-confined one), moved into the
* window when it would leave it. The other side of the cursor is tried before
* clamping, below first vertically. A tooltip taller than the window starts at
* its top. A confined tooltip stays in the chart, leaving it only as far as the
* window needs.
*/
export const windowTooltipPosition =
(
getChartRect: () => DOMRect | undefined,
confine: boolean
): TooltipPositionCallback =>
(point, _params, _dom, _rect, size) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const right = cursorX + ECHARTS_TOOLTIP_GAP_PX;
const left = cursorX - ECHARTS_TOOLTIP_GAP_PX - tipW;
const below = cursorY + ECHARTS_TOOLTIP_GAP_PX;
const above = cursorY - ECHARTS_TOOLTIP_GAP_PX - tipH;
let x = right + tipW + 2 > viewW ? left : right;
let y = below + tipH > viewH ? above : below;
if (confine) {
x = Math.max(0, Math.min(x, viewW - tipW));
y = Math.max(0, Math.min(y, viewH - tipH));
}
const rect = getChartRect();
if (!rect?.height) {
return [x, y];
}
const fit = (
pos: number,
tipSize: number,
alternatives: number[],
windowMin: number,
windowMax: number,
viewSize: number
) => {
if (!confine) {
return fitInto(pos, tipSize, alternatives, windowMin, windowMax);
}
const min = Math.max(0, windowMin);
const max = Math.min(viewSize, windowMax);
return tipSize > max - min
? fitInto(pos, tipSize, [], windowMin, windowMax)
: fitInto(pos, tipSize, alternatives, min, max);
};
const { clientWidth, clientHeight } = document.documentElement;
return [
fit(
x,
tipW,
[right, left],
WINDOW_MARGIN_PX - rect.left,
clientWidth - WINDOW_MARGIN_PX - rect.left,
viewW
),
fit(
y,
tipH,
[below, above],
WINDOW_MARGIN_PX - rect.top,
clientHeight - WINDOW_MARGIN_PX - rect.top,
viewH
),
];
};
+1 -12
View File
@@ -68,7 +68,6 @@ import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { windowTooltipPosition } from "./chart-tooltip-position";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -1253,19 +1252,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
next.appendTo = undefined;
}
if (next.position === undefined) {
// ECharts confines after the position callback, which would undo the
// window fit, so the callback confines itself
next.position = windowTooltipPosition(
() => this._chartContainer?.getBoundingClientRect(),
isMobile || next.confine === true
);
next.confine = false;
} else if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
}
if (isMobile || this._touchInput) {
next.triggerOn = "click";
+4 -10
View File
@@ -31,14 +31,8 @@ export interface NetworkNode {
color?: string;
borderColor?: string;
borderWidth?: number;
opacity?: number;
};
label?: {
position?: "left" | "right" | "top" | "bottom";
show?: boolean;
fontWeight?: "normal" | "bold";
fontSize?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -57,7 +51,6 @@ export interface NetworkLink {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
opacity?: number;
};
symbolSize?: number | number[];
symbol?: string;
@@ -293,8 +286,9 @@ export class HaNetworkGraph extends LitElement {
return node.name ?? "";
},
rich: {
// inherits the node label font, so a node can override it
primary: {},
primary: {
fontSize: 12,
},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
+42 -55
View File
@@ -22,8 +22,6 @@ export interface Node {
color?: string;
passThrough?: boolean;
entityId?: string;
// Nodes of one group take a single position when ordering their children
group?: string;
}
export interface Link {
source: string;
@@ -48,46 +46,6 @@ const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
// Links without a value take the remaining capacity of their nodes, in order.
export const fillSankeyLinkValues = (
nodes: Node[],
rawLinks: Link[]
): ProcessedLink[] => {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
};
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@property({ attribute: false }) public data: SankeyChartData = {
@@ -232,7 +190,7 @@ export class HaSankeyChart extends LitElement {
);
}
});
const links = fillSankeyLinkValues(filteredNodes, data.links);
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
@@ -346,31 +304,60 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
const countedGroups = new Set<string>();
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const positions = [...new Set(sections[i].map((n) => n.group ?? n.id))];
const node = sections[i].find((n) => n.id === parent);
if (node) {
if (node.group !== undefined) {
if (countedGroups.has(node.group)) {
break;
}
countedGroups.add(node.group);
}
sum += offset + positions.indexOf(node.group ?? node.id);
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
count++;
break;
}
offset += positions.length;
offset += section.length;
}
}
return count > 0 ? sum / count : -1;
@@ -161,8 +161,7 @@ export abstract class HaDeviceAutomationPicker<
return html`<ha-generic-picker
.label=${this.label}
.value=${value}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(
value,
this._automations,
+1 -37
View File
@@ -189,10 +189,6 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _loadFailed = false;
private _statisticIdsRequest = 0;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
@@ -205,36 +201,11 @@ export class HaStatisticPicker extends LitElement {
}
private async _getStatisticIds() {
// Changing the statistic types starts a new request, and only the latest
// one may update the picker
const request = ++this._statisticIdsRequest;
this._loadFailed = false;
let statisticIds: StatisticsMetaData[];
try {
statisticIds = await getStatisticIds(this._api, this.statisticTypes);
} catch {
if (request === this._statisticIdsRequest) {
this._loadFailed = true;
}
return;
}
if (request !== this._statisticIdsRequest) {
return;
}
this.statisticIds = statisticIds;
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _retryGetStatisticIds(ev: Event) {
ev.stopPropagation();
this._getStatisticIds();
}
private _getItems = () =>
this._getStatisticsItems(
this._states,
@@ -618,12 +589,6 @@ export class HaStatisticPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.loading=${!this._loadFailed && this.statisticIds === undefined}
.loadError=${
this._loadFailed
? this._i18n.localize("ui.components.statistic-picker.load_failed")
: undefined
}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@@ -633,7 +598,6 @@ export class HaStatisticPicker extends LitElement {
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryGetStatisticIds}
>
</ha-generic-picker>
`;
+3 -9
View File
@@ -13,7 +13,6 @@ import {
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
getAreaEntityIds,
type AreaControlDomain,
} from "../data/area/area_controls";
import {
@@ -149,20 +148,15 @@ export class HaAreaControlsPicker extends LitElement {
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const areaEntities = getAreaEntityIds(
registries.entities,
registries.devices,
areaId,
excludeEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
AREA_CONTROL_DOMAINS
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities
);
const items: (AreaControlPickerItem | string)[] = [];
+93
View File
@@ -0,0 +1,93 @@
import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { preventDefault } from "../common/dom/prevent_default";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-checkbox";
@customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
@property({ type: Boolean, attribute: "separate-checkbox-click" })
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<ha-checkbox
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
class=${this.left ? "left" : ""}
>
</ha-checkbox>
${this.left ? text : ""} ${meta}`;
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
ha-checkbox {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
height: 100%;
justify-content: center;
}
ha-checkbox.left {
margin-inline-start: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-check-list-item": HaCheckListItem;
}
}
-4
View File
@@ -62,10 +62,6 @@ export class HaControlSelect extends LitElement {
});
}
public override focus(): void {
this._focusOption(this._tabbableIndex);
}
private _handleBlur(ev: FocusEvent) {
// Only reset if focus is leaving the entire component
if (!this.contains(ev.relatedTarget as Node)) {
+4 -65
View File
@@ -5,13 +5,10 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import { computeUnitDeviceClasses } from "../data/sensor/unit_of_measurement";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
@@ -30,13 +27,6 @@ export const getDeviceClassOptions = (
return { id: deviceClass, primary, sorting_label: primary };
});
// The reason line of a device class that does not support the unit names the
// unit, so searching it would match every unsupported device class
const UNIT_FILTER_SEARCH_KEYS: FuseWeightedKey[] = [
{ name: "primary", weight: 10 },
{ name: "id", weight: 3 },
];
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@@ -53,9 +43,6 @@ export class HaDeviceClassPicker extends LitElement {
@property({ type: Boolean }) public required = false;
/** Unit of measurement: device classes that do not support it are listed last */
@property() public unit?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -83,60 +70,18 @@ export class HaDeviceClassPicker extends LitElement {
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined,
unit: string | undefined,
language: string | undefined
): PickerComboBoxItem[] => {
if (!domain || !localize) {
return [];
}
const options = getDeviceClassOptions(domain, localize);
const fitting = computeUnitDeviceClasses(domain, unit);
if (!fitting) {
return options;
}
// Device classes of the unit first, then those that accept any unit
const rank = (deviceClass: string) =>
fitting.matching.includes(deviceClass)
? 0
: fitting.unrestricted.includes(deviceClass)
? 1
: 2;
const notSupported = localize(
"ui.components.device-class-picker.unit_not_supported",
{ unit }
);
return options
.map((option) =>
rank(option.id) === 2
? { ...option, secondary: notSupported }
: option
)
.sort(
(a, b) =>
rank(a.id) - rank(b.id) ||
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
}
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
);
// The options are sorted here when the unit filters them
private get _filtered(): boolean {
return computeUnitDeviceClasses(this.domain, this.unit) !== undefined;
}
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(
this.domain,
this._i18n?.localize,
this.unit,
this._i18n?.language
);
const options = this._options(this.domain, this._i18n?.localize);
return this.multiple
? this._availableOptions(options, this._value)
: options;
@@ -159,8 +104,6 @@ export class HaDeviceClassPicker extends LitElement {
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
const filtered = this._filtered;
const searchKeys = filtered ? UNIT_FILTER_SEARCH_KEYS : undefined;
if (this.multiple) {
const value = this._value;
@@ -202,8 +145,6 @@ export class HaDeviceClassPicker extends LitElement {
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
@@ -223,8 +164,6 @@ export class HaDeviceClassPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
@@ -1,78 +0,0 @@
import type { ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeDomain } from "../common/entity/compute_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import { stringCompare } from "../common/string/compare";
import { internationalizationContext, statesContext } from "../data/context";
import { computeDeviceClassName } from "../data/entity/device_class";
import { HaFilterStates } from "./ha-filter-states";
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends HaFilterStates {
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates?: ContextType<typeof statesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string
) => {
const classes = new Map<string, string>();
for (const [entityId, stateObj] of Object.entries(states)) {
const deviceClass = stateObj.attributes.device_class;
if (typeof deviceClass !== "string" || classes.has(deviceClass)) {
continue;
}
classes.set(
deviceClass,
computeDeviceClassName(localize, computeDomain(entityId), deviceClass)
);
}
return [...classes]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => stringCompare(a.label, b.label, language));
}
);
protected firstUpdated() {
this._i18n?.loadBackendTranslation("entity_component");
}
protected willUpdate() {
if (
this._entityStates &&
this._localize &&
this._i18n &&
(this.expanded || !this.states)
) {
this.states = this._deviceClasses(
this._entityStates,
this._localize,
this._i18n.locale.language
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+1 -55
View File
@@ -1,11 +1,10 @@
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -26,9 +25,6 @@ import type { IntegrationManifest } from "../data/integration";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-badge";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -36,14 +32,6 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -52,12 +40,6 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -147,7 +129,6 @@ export class HaFilterIntegrations extends LitElement {
brand-fallback
></ha-domain-icon>
<span slot="headline">${integration.name}</span>
${this._renderCounts(integration.domain)}
</ha-list-item-option>`
)}
</ha-list-selectable>`
@@ -159,32 +140,6 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string): TemplateResult | typeof nothing {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span slot="supporting-text" class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -294,15 +249,6 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
margin-top: var(--ha-space-1);
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+43 -161
View File
@@ -2,7 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { mdiPlaylistPlus, mdiRefresh } from "@mdi/js";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
html,
@@ -17,15 +17,13 @@ import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import { configContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-alert";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
@@ -35,7 +33,6 @@ import type {
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import "./ha-svg-icon";
import "./skeleton/ha-skeleton";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@@ -117,15 +114,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
/** Shows a skeleton in place of the field while its items load. */
@property({ type: Boolean, reflect: true }) public loading = false;
/**
* Shows this error with a retry button in place of the field when its items
* failed to load. The retry button fires `picker-retry`.
*/
@property({ attribute: false }) public loadError?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -136,14 +124,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// Set once loading starts, then hidden with CSS once it ends, so the
// skeleton fades out over the field
@state() private _skeletonShown = false;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -175,14 +155,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("loading") && this.loading) {
this._skeletonShown = true;
}
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState") ||
changedProperties.has("loading") ||
changedProperties.has("loadError")
changedProperties.has("noUnknownState")
) {
this._setUnknownValue();
}
@@ -208,73 +183,45 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: nothing
}
<div id="picker">
<div class="field" ?inert=${this.loading || !!this.loadError}>
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._skeletonShown
? html`<ha-skeleton
class="loading-skeleton"
role=${ifDefined(this.loading ? "progressbar" : undefined)}
aria-label=${ifDefined(
this.loading ? this._loadingLabel() : undefined
)}
></ha-skeleton>`
: nothing
}
${
this.loadError
? html`<div class="load-error">
<ha-alert alert-type="error">
${this.loadError}
<ha-icon-button
slot="action"
.path=${mdiRefresh}
.label=${this._i18n?.localize("ui.common.retry")}
@click=${this._retry}
></ha-icon-button>
</ha-alert>
</div>`
: nothing
}
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._pickerWrapperOpen || this._opened
@@ -351,25 +298,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _loadingLabel() {
const label = this.ariaLabel || this.label || this.placeholder;
return label
? this._i18n?.localize("ui.components.generic-picker.loading", {
label,
})
: this._i18n?.localize("ui.common.loading");
}
private _retry(ev: Event) {
ev.stopPropagation();
fireEvent(this, "picker-retry");
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.loading ||
this.loadError ||
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
@@ -482,8 +413,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
) {
ev?.stopPropagation();
if (this.disabled || this.loading || this.loadError) {
if (this.disabled) {
return;
}
this._selectedValue = options?.selectedValue;
@@ -529,53 +459,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block;
max-width: 100%;
}
/* The error shares the field's cell, so a wrapping error grows the
picker instead of covering what follows */
#picker {
position: relative;
display: grid;
}
#picker > .field,
#picker > .load-error {
grid-area: 1 / 1;
min-width: 0;
}
/* The field keeps its place under the skeleton, so nothing moves and
the two cross fade once loading ends */
.field {
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
:host([loading]) .field,
.field[inert] {
opacity: 0;
}
.loading-skeleton {
position: absolute;
inset: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-sm);
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
@starting-style {
.loading-skeleton {
opacity: 0;
}
}
/* Hidden once faded out, and at once when no fade runs */
:host(:not([loading])) .loading-skeleton {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease-out,
visibility 0s linear var(--ha-animation-duration-fast, 150ms);
}
.load-error {
display: flex;
align-items: center;
}
.load-error ha-alert {
flex: 1;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
@@ -657,6 +540,5 @@ declare global {
interface HASSDomEvents {
"picker-closed": undefined;
"picker-opened": undefined;
"picker-retry": undefined;
}
}
-2
View File
@@ -86,7 +86,6 @@ class HaMarkdownElement extends ReactiveElement {
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
rewriteUrl: Boolean(this.rewriteUrl),
});
}
@@ -100,7 +99,6 @@ class HaMarkdownElement extends ReactiveElement {
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
allowRelativeUrls: Boolean(this.rewriteUrl),
}
);
+1 -2
View File
@@ -178,8 +178,7 @@ export class HaNavigationPicker extends LitElement {
allow-custom-value
.placeholder=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.loading=${this._loading}
.disabled=${this._loading || this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
-6
View File
@@ -477,12 +477,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-id=${section.id}
.selected=${this._selectedSection === section.id}
.label=${section.label}
aria-label=${
this.i18n?.localize?.(
"ui.components.combo-box.filter_by_section",
{ section: section.label }
) || section.label
}
>
</ha-filter-chip>`
)}
@@ -1,9 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { DeviceClassSelector } from "../../data/selector";
import { computeDeviceClassForUnit } from "../../data/sensor/unit_of_measurement";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
@@ -20,17 +17,12 @@ export class HaDeviceClassSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
filter_unit_of_measurement?: string;
};
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.unit=${this.context?.filter_unit_of_measurement}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -39,39 +31,6 @@ export class HaDeviceClassSelector extends LitElement {
`;
}
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
// A unit does not imply a device class, so an optional field stays empty
if (
!this.required ||
!changedProps.has("context") ||
this.value ||
this.selector.device_class?.multiple
) {
return;
}
// Preselect only when the unit changes, so a form that opens with a unit
// and a device class the user cleared stay without a device class
const oldContext = changedProps.get("context");
const unit = this.context?.filter_unit_of_measurement;
if (
oldContext === undefined ||
oldContext.filter_unit_of_measurement === unit
) {
return;
}
// Preselect the only device class of the unit
const deviceClass = computeDeviceClassForUnit(
this.selector.device_class?.domain,
unit
);
if (deviceClass) {
fireEvent(this, "value-changed", { value: deviceClass });
}
}
static styles = css`
ha-device-class-picker {
width: 100%;
@@ -162,7 +162,7 @@ export class HaObjectSelector extends LitElement {
description = this._formatField(item, descriptionField);
}
const reorderable = this._reorderable;
const reorderable = this.selector.object!.multiple || false;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-list-item-base class="item">
@@ -212,7 +212,6 @@ export class HaObjectSelector extends LitElement {
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
.disabled=${!this._reorderable}
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
@@ -292,18 +291,11 @@ export class HaObjectSelector extends LitElement {
}));
});
private get _reorderable(): boolean {
return (
(this.selector.object?.multiple ?? false) &&
this.selector.object?.reorder !== false
);
}
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this._reorderable) {
if (!this.selector.object!.multiple) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
+1 -187
View File
@@ -1,14 +1,7 @@
import TabGroup from "@home-assistant/webawesome/dist/components/tab-group/tab-group";
import { css, type CSSResultGroup, type PropertyValues } from "lit";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import type { SortableEvent } from "sortablejs";
import { DragScrollController } from "../common/controllers/drag-scroll-controller";
import { fireEvent } from "../common/dom/fire_event";
import type { SortableInstance } from "../resources/sortable";
const TOUCH_DRAG_DELAY_MS = 300;
const AUTO_SCROLL_SENSITIVITY = 30;
@customElement("ha-tab-group")
export class HaTabGroup extends TabGroup {
@@ -20,199 +13,24 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
/**
* Allow reordering tabs by dragging or with Alt+ArrowUp/ArrowDown. Fires
* `item-moved`; the owner applies the new order.
*/
@property({ type: Boolean }) public sortable = false;
/** Drag handle that starts a touch drag without the long press. */
@property({ attribute: "handle-selector" }) public handleSelector?: string;
private _sortable?: SortableInstance;
private _sortPlaceholder?: Comment;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
if (this.hasUpdated && this.sortable) {
this._createSortable();
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
this._destroySortable();
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("sortable")) {
// Dragging the tab bar to scroll would compete with dragging a tab.
this._dragScrollController.enabled = !this.sortable;
if (this.sortable) {
this._createSortable();
} else {
this._destroySortable();
}
}
}
private async _createSortable() {
if (this._sortable) {
return;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const Sortable = (await import("../resources/sortable")).default;
if (this._sortable || !this.sortable || !this.isConnected) {
return;
}
// supportPointer is missing from the SortableJS typings.
const options: SortableInstance.Options & { supportPointer?: boolean } = {
draggable: this.tabTag,
// SortableJS guesses vertical from the tabs being display: block.
direction:
this.placement === "start" || this.placement === "end"
? "vertical"
: "horizontal",
// The tab bar scrolls inside the shadow root, where SortableJS can't find it.
scroll: this.nav,
// Force the autoscroll fallback because it works better than the native one
forceAutoScrollFallback: true,
scrollSensitivity: AUTO_SCROLL_SENSITIVITY,
scrollSpeed: 20,
animation: 150,
// Keep swiping on touch screens for scrolling the tab bar.
delay: TOUCH_DRAG_DELAY_MS,
delayOnTouchOnly: true,
// A scrolling swipe ends in pointercancel, after which SortableJS
// ignores the next drag. Touch events end with touchend instead.
supportPointer: false,
onStart: this._handleSortableStart,
onEnd: this._handleSortableEnd,
onUpdate: this._handleSortableUpdate,
};
this._sortable = new Sortable(this, options);
// Capture, so the delay is set before SortableJS reads it.
this.addEventListener("touchstart", this._handleTouchStart, {
capture: true,
passive: true,
});
}
private _destroySortable() {
this._sortable?.destroy();
this._sortable = undefined;
this.removeEventListener("touchstart", this._handleTouchStart, true);
this._removeRtlAutoScrollListeners();
}
private _handleTouchStart = (ev: TouchEvent) => {
const onHandle =
!!this.handleSelector &&
ev
.composedPath()
.some(
(el) => el instanceof Element && el.matches(this.handleSelector!)
);
this._sortable!.option("delay", onHandle ? 0 : TOUCH_DRAG_DELAY_MS);
};
private _handleSortableStart = (evt: SortableEvent) => {
// Put the tab back after dropping, the owner renders the new order.
this._sortPlaceholder = document.createComment("sort-placeholder");
evt.item.after(this._sortPlaceholder);
// Touch drags add a copy of the tab to this element, which the tab group
// would show and select as an extra tab. The dimmed tab shows the position.
this.querySelector(":scope > .sortable-fallback")?.remove();
if (getComputedStyle(this.nav).direction !== "rtl") {
return;
}
// Capture, so this runs before SortableJS checks for auto scroll.
const options = { capture: true, passive: true };
window.addEventListener("dragover", this._unblockRtlAutoScroll, options);
window.addEventListener("touchmove", this._unblockRtlAutoScroll, options);
};
private _removeRtlAutoScrollListeners() {
window.removeEventListener("dragover", this._unblockRtlAutoScroll, true);
window.removeEventListener("touchmove", this._unblockRtlAutoScroll, true);
}
// SortableJS only auto scrolls left while scrollLeft isn't 0, but in RTL the
// tab bar starts at 0 and scrolls left into negative values.
private _unblockRtlAutoScroll = (ev: DragEvent | TouchEvent) => {
const x = "touches" in ev ? ev.touches[0]?.clientX : ev.clientX;
if (x === undefined || this.nav.scrollLeft !== 0) {
return;
}
const left = this.nav.getBoundingClientRect().left;
if (Math.abs(x - left) <= AUTO_SCROLL_SENSITIVITY) {
this.nav.scrollLeft = -1;
}
};
private _handleSortableEnd = (evt: SortableEvent) => {
this._removeRtlAutoScrollListeners();
this._sortPlaceholder?.replaceWith(evt.item);
this._sortPlaceholder = undefined;
};
private _handleSortableUpdate = (evt: SortableEvent) => {
fireEvent(this, "item-moved", {
oldIndex: evt.oldIndex!,
newIndex: evt.newIndex!,
});
};
private _handleKeyDown = (event: KeyboardEvent) => {
// The tab group cancels Enter and Space, so buttons inside a tab would not
// be pressed.
if (
(event.key === "Enter" || event.key === " ") &&
event.composedPath()[0] instanceof HTMLButtonElement &&
(event.target as HTMLElement).closest(this.tabTag)
) {
event.stopPropagation();
return;
}
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
if (
this.sortable &&
event.altKey &&
!(event.ctrlKey || event.metaKey) &&
!event.shiftKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
this._moveTabWithKeyboard(event);
}
};
// Up and down follow the tab order, so they work the same in RTL.
private _moveTabWithKeyboard(event: KeyboardEvent) {
const tab = (event.target as HTMLElement).closest(this.tabTag);
if (!tab) {
return;
}
const tabs = [...this.children].filter(
(el) => el.localName === this.tabTag
);
const oldIndex = tabs.indexOf(tab);
const newIndex = oldIndex + (event.key === "ArrowUp" ? -1 : 1);
event.preventDefault();
if (oldIndex === -1 || newIndex < 0 || newIndex >= tabs.length) {
return;
}
fireEvent(this, "item-moved", { oldIndex, newIndex });
}
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;
@@ -249,10 +67,6 @@ export class HaTabGroup extends TabGroup {
margin-inline-start: calc(-1 * var(--track-width));
}
::slotted(.sortable-ghost) {
opacity: 0.4;
}
.scroll-button::part(base):hover {
background-color: transparent;
}
+219 -22
View File
@@ -41,7 +41,9 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
extractFromTarget,
getTargetComboBoxItemType,
type ExtractFromTargetResult,
type HassServiceTargetWithExclude,
type TargetItem,
type TargetType,
@@ -68,15 +70,21 @@ import "./list/ha-list-base";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-item-row";
import { showTargetDetailsDialog } from "./target-picker/dialog/show-dialog-target-details";
import { TargetExclusionsController } from "./target-picker/target-exclusions-controller";
import { filterTargetEntries } from "./target-picker/filter-target-entries";
import {
addToExclude,
applyExclusionChanges,
computeExclusionsPerTarget,
hasIncludedTargets,
removeFromExclude,
resolveTargetItemEntities,
TARGET_TYPES,
targetItemKey,
targetItems,
withExclude,
type ResolvedTargetItem,
type TargetExclusion,
type TargetExclusions,
} from "./target-picker/target-exclusions";
const SEPARATOR = "________";
@@ -143,6 +151,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _excludeSection?: TargetTypeFloorless;
// What each exclusion takes out of each target, worked out from the value.
@state() private _exclusions?: TargetExclusions;
// Whether every included target and every exclusion resolved, so it is
// known what each exclusion takes out, which ones are shared with another
// target, and the details can edit them.
@state() private _exclusionsComplete = true;
// Every entity the included targets resolve to, so the exclude picker only
// offers what would take something out.
private _includedEntities?: string[];
private _resolveRequest = 0;
// What each target item resolves to, so a change only asks core about the
// items it adds.
private _itemEntries = new Map<string, Promise<ExtractFromTargetResult>>();
@state() private _replaceTarget?: TargetItem;
@state() private _replaceTargetAnchor?: HTMLElement;
@@ -163,8 +189,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _newTarget?: TargetItem;
private _exclusions = new TargetExclusionsController(this);
private _getDevicesMemoized = memoizeOne(
(
hass: HomeAssistant,
@@ -278,6 +302,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
(devicesChanged ||
this.hass.entities !== changedProps.get("hass")?.entities ||
this.hass.areas !== changedProps.get("hass")?.areas);
if (registriesChanged) {
this._itemEntries.clear();
}
if (
changedProps.has("value") ||
changedProps.has("allowExclude") ||
@@ -288,7 +315,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
changedProps.has("entityFilter") ||
registriesChanged
) {
this._exclusions.resolve();
this._resolveExclusions();
}
if (
@@ -324,9 +351,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
? ensureArray(this.value?.label_id)
: [];
const noEffect = this._exclusions.exclusions?.noEffect ?? [];
const unplaced = this._exclusions.unplaced;
const perTarget = this._exclusions.perTarget;
const noEffect = this._exclusions?.noEffect ?? [];
const unplaced = this._unplacedExclusions(
this.value?.exclude,
this._exclusions
);
const perTarget = this._exclusionsPerTarget();
if (
!entityIds.length &&
@@ -467,6 +497,25 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
`;
}
// Excludes that aren't listed under a target or as having no effect,
// because they are still being resolved or resolving them failed.
private _unplacedExclusions = memoizeOne(
(
exclude: HassServiceTarget | undefined,
exclusions: TargetExclusions | undefined
): TargetItem[] => {
const placed = new Set([
...Object.values(exclusions?.perTarget ?? {}).flatMap((list) =>
list.map(targetItemKey)
),
...(exclusions?.noEffect ?? []).map(targetItemKey),
]);
return targetItems(exclude).filter(
(item) => !placed.has(targetItemKey(item))
);
}
);
private _renderItems() {
return html` ${this._renderValueGroups()} `;
}
@@ -563,13 +612,155 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
];
}
private _extractItem(
item: TargetItem,
primaryEntitiesOnly: boolean
): Promise<ExtractFromTargetResult> {
const key = `${primaryEntitiesOnly}:${targetItemKey(item)}`;
let entries = this._itemEntries.get(key);
if (!entries) {
entries = extractFromTarget(
this.hass.callWS,
{ [`${item.type}_id`]: [item.id] },
false,
primaryEntitiesOnly
);
this._itemEntries.set(key, entries);
const cached = entries;
cached.catch(() => {
if (this._itemEntries.get(key) === cached) {
this._itemEntries.delete(key);
}
});
}
return entries;
}
// The entities an included item adds, filtered like its row so what an
// exclusion takes out matches what the row counts.
private async _resolveInclude(item: TargetItem): Promise<string[]> {
if (item.type === "entity") {
return resolveTargetItemEntities(this.hass, item);
}
const entries = await this._extractItem(
item,
this.primaryEntitiesOnly ?? true
);
return filterTargetEntries(
this.hass.states,
this.hass.entities,
this.hass.devices,
this.hass.areas,
item,
entries,
{
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
}
).referenced_entities;
}
// Like core, an exclude takes out every entity of an item, not only the
// primary ones, and isn't narrowed by the filters.
private async _resolveExclude(item: TargetItem): Promise<string[]> {
if (item.type === "entity") {
return resolveTargetItemEntities(this.hass, item);
}
return (await this._extractItem(item, false)).referenced_entities;
}
private async _resolveExclusions() {
const request = ++this._resolveRequest;
this._includedEntities = undefined;
const value = this.value;
const excludes = targetItems(value?.exclude);
const includes = targetItems(value);
// Without an exclude, the targets are only needed to tell whether an
// exclusion made in the details is shared with another target.
if (
!this.hass ||
(!excludes.length && (!this.allowExclude || includes.length < 2))
) {
this._exclusions = undefined;
this._exclusionsComplete = true;
return;
}
// Until the targets resolve, the exclusions shown are from before and
// may not say which ones are shared now.
this._exclusionsComplete = false;
// Each item on its own, so it is known which exclusion takes out what
// from which target. One that fails only loses its own part.
const [includeResults, excludeResults] = await Promise.all([
Promise.allSettled(includes.map((item) => this._resolveInclude(item))),
Promise.allSettled(excludes.map((item) => this._resolveExclude(item))),
]);
if (request !== this._resolveRequest) {
return;
}
const resolvedIncludes: ResolvedTargetItem[] = [];
includes.forEach((item, index) => {
const result = includeResults[index];
if (result.status === "fulfilled") {
resolvedIncludes.push({ ...item, entities: result.value });
}
});
const resolvedExcludes: ResolvedTargetItem[] = [];
excludes.forEach((item, index) => {
const result = excludeResults[index];
if (result.status === "fulfilled") {
resolvedExcludes.push({ ...item, entities: result.value });
}
});
const allIncluded = resolvedIncludes.length === includes.length;
const exclusions = computeExclusionsPerTarget(
resolvedIncludes,
resolvedExcludes
);
if (!allIncluded) {
// Without every target, no effect can't be told from not known, and a
// shared exclusion can't be told from one that isn't.
exclusions.noEffect = [];
}
this._exclusions = exclusions;
this._exclusionsComplete =
allIncluded && resolvedExcludes.length === excludes.length;
this._includedEntities = allIncluded
? [
...new Set(
includeResults.flatMap((result) =>
result.status === "fulfilled" ? result.value : []
)
),
]
: undefined;
}
// Without an exclude yet, what the target includes is only needed once the
// exclude picker opens, so it only offers what would take something out.
private async _openExcludePicker(ev: Event) {
this._excludePicker?.open(ev);
if (!this._exclusions.includedEntities) {
await this._exclusions.resolve({ force: true });
// exclude picker opens.
private async _resolveIncludedEntities() {
const request = this._resolveRequest;
try {
const included = await Promise.all(
targetItems(this.value).map((item) => this._resolveInclude(item))
);
if (request !== this._resolveRequest) {
return;
}
this._includedEntities = [...new Set(included.flat())];
this._excludePicker?.refreshItems();
} catch (_err) {
// The picker then offers everything instead.
}
}
private _openExcludePicker(ev: Event) {
this._excludePicker?.open(ev);
if (!this._includedEntities) {
this._resolveIncludedEntities();
}
}
@@ -600,17 +791,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _exclusionsPerTarget():
Record<string, TargetExclusion[]> | undefined {
return (
this._exclusions?.perTarget ??
(this.allowExclude && !hasIncludedTargets(this.value?.exclude)
? {}
: undefined)
);
}
private _openDetails(ev: HASSDomEvent<HASSDomEvents["open-target-details"]>) {
ev.stopPropagation();
const { type, id, title } = ev.detail;
const key = targetItemKey({ type, id });
const perTarget = this._exclusions.perTarget;
const perTarget = this._exclusionsPerTarget();
const exclusions = perTarget ? (perTarget[key] ?? []) : undefined;
const editable =
exclusions &&
this.allowExclude &&
!this.disabled &&
this._exclusions.complete;
this._exclusionsComplete;
showTargetDetailsDialog(this, {
title,
type,
@@ -623,10 +824,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
primaryEntitiesOnly: this.primaryEntitiesOnly,
exclusions,
exclude: this.value?.exclude,
expandGroup: this._exclusions.expandGroup,
explainExclusion: editable
? (item) => this._exclusions.explainExclusion(key, item)
: undefined,
otherTargetEntities: Object.entries(
this._exclusions?.included ?? {}
).flatMap(([targetKey, entities]) => (targetKey === key ? [] : entities)),
exclusionsChanged: editable
? (initial, current) => {
fireEvent(this, "value-changed", {
@@ -688,10 +888,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return this._getItemsMemoized(
this.hass.localize,
this._excludeEntityFilter(
this.entityFilter,
this._exclusions.includedEntities
),
this._excludeEntityFilter(this.entityFilter, this._includedEntities),
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
+13 -24
View File
@@ -45,7 +45,7 @@ import {
distanceMeters,
} from "../../common/map/map-engine";
import type { MapStyleConfig } from "../../common/map/map-styles";
import { isDarkMapStyle, resolveMapStyle } from "../../common/map/map-styles";
import { resolveMapStyle } from "../../common/map/map-styles";
import { readMapThemeColors } from "../../common/map/map-theme-colors";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
@@ -618,15 +618,15 @@ export class HaMap extends ReactiveElement {
});
}
if (changedProps.has("mapStyle")) {
this._engine?.setMapStyle(this._resolvedMapStyle);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
) {
// A style can have one half only, so the chrome follows it too
if (changedProps.has("mapStyle")) {
this._updateMapAppearance();
}
return;
}
@@ -647,11 +647,6 @@ export class HaMap extends ReactiveElement {
);
}
// What the map draws, which a style with one half decides over the theme mode
private get _styleDark() {
return isDarkMapStyle(this.mapStyle, this._darkMode);
}
// Memoized: it is read on every appearance sync, and the engines compare
// what they are handed against what they applied.
private _resolveMapStyle = memoizeOne(resolveMapStyle);
@@ -664,12 +659,12 @@ export class HaMap extends ReactiveElement {
// arguments, and a fresh object per read would defeat it.
private _readThemeColors(): void {
// Only one set of --ha-color-map-* is live on the page, the one for the
// mode the page is in. A card forced to the other mode, or a style with
// only the other half, draws the other palette, so those values would be
// the wrong half of the theme; the style's own colors are the better answer.
// mode the page is in. A card forced to the other mode draws the other
// palette, so those values would be the wrong half of the theme; the
// style's own colors are the better answer there.
const pageDark = Boolean(this._ui?.themes?.darkMode);
const colors =
this._styleDark === pageDark ? readMapThemeColors(this) : undefined;
this._darkMode === pageDark ? readMapThemeColors(this) : undefined;
if (!deepEqual(colors, this._themeColors)) {
this._themeColors = colors;
}
@@ -695,19 +690,13 @@ export class HaMap extends ReactiveElement {
return;
}
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._styleDark);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("drawn", this._mapDrawn);
// The sky belongs behind a drawn globe; on a blank canvas it is just a
// gradient with a glow in it
map.classList.toggle("space", this._vectorEngine && this._mapDrawn);
map.classList.toggle(
"forced-dark",
this._styleDark && (this.themeMode === "dark" || !this._darkMode)
);
map.classList.toggle(
"forced-light",
!this._styleDark && (this.themeMode === "light" || this._darkMode)
);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._engine?.setMapStyle(this._resolvedMapStyle);
}
@@ -776,7 +765,7 @@ export class HaMap extends ReactiveElement {
map.id = "map";
// Which ground shows in the gap before the first frame; the rest of the
// classes wait for the engine (_updateMapAppearance)
map.classList.toggle("dark", this._styleDark);
map.classList.toggle("dark", this._darkMode);
this.shadowRoot!.append(map);
this._loading = true;
this._mapDrawn = false;
Loaded 100 of 371 files, more files were not shown because too many files have changed in this diff. Show more