mirror of
https://github.com/home-assistant/frontend.git
synced 2025-10-28 13:09:46 +00:00
Compare commits
117 Commits
cursor/ena
...
chat-log-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7db545fe | ||
|
|
d592230ae4 | ||
|
|
164ec2a9b5 | ||
|
|
20001a551c | ||
|
|
b7f85bf733 | ||
|
|
b303e9441b | ||
|
|
8f4bd0f620 | ||
|
|
596346bf59 | ||
|
|
769cea92aa | ||
|
|
f825016514 | ||
|
|
c6fd45bd6a | ||
|
|
6c4f4af75c | ||
|
|
cd5c3ef2f6 | ||
|
|
636a6fa02e | ||
|
|
21b83426d6 | ||
|
|
c139ec22f9 | ||
|
|
a6ef3a26da | ||
|
|
221ca56121 | ||
|
|
4e6e3629a8 | ||
|
|
fe94ae0243 | ||
|
|
8a1a22d4bd | ||
|
|
153a578986 | ||
|
|
04bb10d0a2 | ||
|
|
35e52de2c1 | ||
|
|
b0862fddaa | ||
|
|
77735f5310 | ||
|
|
e388756533 | ||
|
|
e9ca9bb781 | ||
|
|
e48918442c | ||
|
|
52f37f41f0 | ||
|
|
4687006fec | ||
|
|
aca4ca3066 | ||
|
|
3a2c00622a | ||
|
|
699c25a6c3 | ||
|
|
1ad226d608 | ||
|
|
992a4cd98a | ||
|
|
fd217f8ea5 | ||
|
|
dede14e578 | ||
|
|
fa7aca67e5 | ||
|
|
6abdfa6d5c | ||
|
|
0a70e2abda | ||
|
|
1ec589e9b6 | ||
|
|
2d2b5633c4 | ||
|
|
76df75c306 | ||
|
|
027ded61c2 | ||
|
|
a718589ba0 | ||
|
|
5b5dc9d853 | ||
|
|
2a49b5e15a | ||
|
|
fa4dd1c5ea | ||
|
|
37a3af2e8b | ||
|
|
fbfcef1573 | ||
|
|
4eecd37aaf | ||
|
|
c798521ab8 | ||
|
|
e432f0a8ee | ||
|
|
e3a1d0abe2 | ||
|
|
8080ba696c | ||
|
|
7bd8f321a4 | ||
|
|
4e958302b4 | ||
|
|
8a42d15bde | ||
|
|
ef0da0a7ee | ||
|
|
ae053c20b0 | ||
|
|
5f71938d60 | ||
|
|
82ac26b326 | ||
|
|
80b92b9813 | ||
|
|
904a083f61 | ||
|
|
d75ee09d55 | ||
|
|
a8e0d506b6 | ||
|
|
01dd731622 | ||
|
|
dc20702d36 | ||
|
|
f32ca9be29 | ||
|
|
8c4c4157a8 | ||
|
|
c8419d4c3d | ||
|
|
089316b8ae | ||
|
|
8d03ac5f64 | ||
|
|
e0e1f6f920 | ||
|
|
d4c98cae3a | ||
|
|
46d0eb4f44 | ||
|
|
07812f8d84 | ||
|
|
96f54d348f | ||
|
|
6084ab116f | ||
|
|
6b7acd8d3b | ||
|
|
e35b155c66 | ||
|
|
437d02c12f | ||
|
|
9cd74fbff8 | ||
|
|
33a7aacd83 | ||
|
|
39546615bb | ||
|
|
be51cbc944 | ||
|
|
77874aa2d7 | ||
|
|
4808463d5f | ||
|
|
5fb3cab247 | ||
|
|
d1093b187f | ||
|
|
fd7f0d3841 | ||
|
|
36aa74e4a5 | ||
|
|
938128d1c3 | ||
|
|
2a5d4ac578 | ||
|
|
be63ff7702 | ||
|
|
132c68bf20 | ||
|
|
16499bbd6b | ||
|
|
c7eddfed8f | ||
|
|
150842e431 | ||
|
|
9eb5360a68 | ||
|
|
e9e32c7d91 | ||
|
|
c83d760e82 | ||
|
|
489b7f9227 | ||
|
|
ad2ba63155 | ||
|
|
29bc894dbd | ||
|
|
faf6cb6333 | ||
|
|
a2e1e6362b | ||
|
|
3212ab6f3b | ||
|
|
3d27daad80 | ||
|
|
b679f1ce60 | ||
|
|
6b0a5d783b | ||
|
|
23e2f94d11 | ||
|
|
c250777858 | ||
|
|
c35d0da9bd | ||
|
|
794aa45a2b | ||
|
|
d0b85d0c0b |
4
.github/workflows/cast_deployment.yaml
vendored
4
.github/workflows/cast_deployment.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
ref: dev
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
ref: master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -36,14 +36,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -57,4 +57,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
|
||||
4
.github/workflows/demo_deployment.yaml
vendored
4
.github/workflows/demo_deployment.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
ref: dev
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
ref: master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/design_deployment.yaml
vendored
2
.github/workflows/design_deployment.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/design_preview.yaml
vendored
2
.github/workflows/design_preview.yaml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/nightly.yaml
vendored
2
.github/workflows/nightly.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/relative-ci.yaml
vendored
2
.github/workflows/relative-ci.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@1707825cbfcc7452b2913d273414705415ae64d4 # v3.0.1
|
||||
uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0
|
||||
with:
|
||||
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
14
.github/workflows/release.yaml
vendored
14
.github/workflows/release.yaml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
script/release
|
||||
|
||||
- name: Upload release assets
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
with:
|
||||
files: |
|
||||
dist/*.whl
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
uses: home-assistant/wheels@2025.10.0
|
||||
with:
|
||||
abi: cp313
|
||||
tag: musllinux_1_2
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
- name: Tar folder
|
||||
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
with:
|
||||
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -137,6 +137,6 @@ jobs:
|
||||
- name: Tar folder
|
||||
run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build .
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
with:
|
||||
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz
|
||||
|
||||
54
package.json
54
package.json
@@ -28,32 +28,32 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.28.4",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@codemirror/autocomplete": "6.19.0",
|
||||
"@codemirror/commands": "6.9.0",
|
||||
"@codemirror/autocomplete": "6.19.1",
|
||||
"@codemirror/commands": "6.10.0",
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.5.11",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.5",
|
||||
"@codemirror/view": "6.38.6",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "6.18.1",
|
||||
"@formatjs/intl-displaynames": "6.8.12",
|
||||
"@formatjs/intl-durationformat": "0.7.5",
|
||||
"@formatjs/intl-datetimeformat": "6.18.2",
|
||||
"@formatjs/intl-displaynames": "6.8.13",
|
||||
"@formatjs/intl-durationformat": "0.7.6",
|
||||
"@formatjs/intl-getcanonicallocales": "2.5.6",
|
||||
"@formatjs/intl-listformat": "7.7.12",
|
||||
"@formatjs/intl-locale": "4.2.12",
|
||||
"@formatjs/intl-numberformat": "8.15.5",
|
||||
"@formatjs/intl-pluralrules": "5.4.5",
|
||||
"@formatjs/intl-relativetimeformat": "11.4.12",
|
||||
"@formatjs/intl-listformat": "7.7.13",
|
||||
"@formatjs/intl-locale": "4.2.13",
|
||||
"@formatjs/intl-numberformat": "8.15.6",
|
||||
"@formatjs/intl-pluralrules": "5.4.6",
|
||||
"@formatjs/intl-relativetimeformat": "11.4.13",
|
||||
"@fullcalendar/core": "6.1.19",
|
||||
"@fullcalendar/daygrid": "6.1.19",
|
||||
"@fullcalendar/interaction": "6.1.19",
|
||||
"@fullcalendar/list": "6.1.19",
|
||||
"@fullcalendar/luxon3": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@home-assistant/webawesome": "3.0.0-beta.6.ha.4",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
|
||||
"@lezer/highlight": "1.2.2",
|
||||
"@lit-labs/motion": "1.0.9",
|
||||
"@lit-labs/observers": "2.0.6",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
@@ -99,7 +99,7 @@
|
||||
"barcode-detector": "3.0.6",
|
||||
"color-name": "2.0.2",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.45.1",
|
||||
"core-js": "3.46.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
@@ -114,7 +114,7 @@
|
||||
"hls.js": "1.6.13",
|
||||
"home-assistant-js-websocket": "9.5.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "10.7.17",
|
||||
"intl-messageformat": "10.7.18",
|
||||
"js-yaml": "4.1.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -122,7 +122,7 @@
|
||||
"lit": "3.3.1",
|
||||
"lit-html": "3.3.1",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "16.4.0",
|
||||
"marked": "16.4.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.3",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -135,7 +135,7 @@
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "3.0.0",
|
||||
"ua-parser-js": "2.0.5",
|
||||
"ua-parser-js": "2.0.6",
|
||||
"vue": "2.7.16",
|
||||
"vue2-daterange-picker": "0.6.8",
|
||||
"weekstart": "2.0.0",
|
||||
@@ -153,11 +153,11 @@
|
||||
"@babel/plugin-transform-runtime": "7.28.3",
|
||||
"@babel/preset-env": "7.28.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.5",
|
||||
"@lokalise/node-api": "15.3.0",
|
||||
"@lokalise/node-api": "15.3.1",
|
||||
"@octokit/auth-oauth-device": "8.0.2",
|
||||
"@octokit/plugin-retry": "8.0.2",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@rsdoctor/rspack-plugin": "1.3.1",
|
||||
"@rsdoctor/rspack-plugin": "1.3.4",
|
||||
"@rspack/core": "1.5.8",
|
||||
"@rspack/dev-server": "1.1.4",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
@@ -167,7 +167,7 @@
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
@@ -178,19 +178,19 @@
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/coverage-v8": "4.0.1",
|
||||
"babel-loader": "10.0.0",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"del": "8.0.1",
|
||||
"eslint": "9.37.0",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-lit": "2.1.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.2.0",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"eslint-plugin-wc": "3.0.2",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.2",
|
||||
@@ -201,9 +201,9 @@
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "27.0.0",
|
||||
"jsdom": "27.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.2.3",
|
||||
"lint-staged": "16.2.6",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.5.0",
|
||||
@@ -217,9 +217,9 @@
|
||||
"terser-webpack-plugin": "5.3.14",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.46.0",
|
||||
"typescript-eslint": "8.46.2",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"vitest": "4.0.1",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
||||
|
||||
@@ -9,6 +9,11 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
@@ -24,14 +29,14 @@ export interface EntityNameOptions {
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
name: EntityNameItem | EntityNameItem[] | undefined,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameOptions
|
||||
) => {
|
||||
let items = ensureArray(name);
|
||||
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
|
||||
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ interface AreaContext {
|
||||
}
|
||||
export const getAreaContext = (
|
||||
area: AreaRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
hassFloors: HomeAssistant["floors"]
|
||||
): AreaContext => {
|
||||
const floorId = area.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : undefined;
|
||||
const floor = floorId ? hassFloors[floorId] : undefined;
|
||||
|
||||
return {
|
||||
area: area,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import "./ha-progress-button";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { Appearance } from "../ha-button";
|
||||
|
||||
@customElement("ha-call-service-button")
|
||||
class HaCallServiceButton extends LitElement {
|
||||
@@ -25,12 +26,14 @@ class HaCallServiceButton extends LitElement {
|
||||
|
||||
@property() public confirmation?;
|
||||
|
||||
@property() public appearance: Appearance = "plain";
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<ha-progress-button
|
||||
.progress=${this.progress}
|
||||
.disabled=${this.disabled}
|
||||
appearance="plain"
|
||||
.appearance=${this.appearance}
|
||||
@click=${this._buttonTapped}
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { LineSeriesOption } from "echarts";
|
||||
|
||||
export function downSampleLineData(
|
||||
data: LineSeriesOption["data"],
|
||||
chartWidth: number,
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
data: T[] | undefined,
|
||||
maxDetails: number,
|
||||
minX?: number,
|
||||
maxX?: number
|
||||
) {
|
||||
if (!data || data.length < 10) {
|
||||
return data;
|
||||
): T[] {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const width = chartWidth * window.devicePixelRatio;
|
||||
if (data.length <= width) {
|
||||
if (data.length <= maxDetails) {
|
||||
return data;
|
||||
}
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.floor((max - min) / width);
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
const frames = new Map<
|
||||
number,
|
||||
{
|
||||
@@ -47,7 +48,7 @@ export function downSampleLineData(
|
||||
}
|
||||
|
||||
// Convert frames back to points
|
||||
const result: typeof data = [];
|
||||
const result: T[] = [];
|
||||
for (const [_i, frame] of frames) {
|
||||
// Use min/max points to preserve visual accuracy
|
||||
// The order of the data must be preserved so max may be before min
|
||||
|
||||
@@ -22,7 +22,7 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { themesContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -88,9 +88,19 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _lastTapTime?: number;
|
||||
|
||||
private _shouldResizeChart = false;
|
||||
|
||||
// @ts-ignore
|
||||
private _resizeController = new ResizeController(this, {
|
||||
callback: () => this.chart?.resize(),
|
||||
callback: () => {
|
||||
if (this.chart) {
|
||||
if (!this.chart.getZr().animation.isFinished()) {
|
||||
this._shouldResizeChart = true;
|
||||
} else {
|
||||
this.chart.resize();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
private _loading = false;
|
||||
@@ -346,7 +356,7 @@ export class HaChartBase extends LitElement {
|
||||
if (this.chart) {
|
||||
this.chart.dispose();
|
||||
}
|
||||
const echarts = (await import("../../resources/echarts")).default;
|
||||
const echarts = (await import("../../resources/echarts/echarts")).default;
|
||||
|
||||
if (this.extraComponents?.length) {
|
||||
echarts.use(this.extraComponents);
|
||||
@@ -366,6 +376,7 @@ export class HaChartBase extends LitElement {
|
||||
if (!this.options?.dataZoom) {
|
||||
this.chart.getZr().on("dblclick", this._handleClickZoom);
|
||||
}
|
||||
this.chart.on("finished", this._handleChartRenderFinished);
|
||||
if (this._isTouchDevice) {
|
||||
this.chart.getZr().on("click", (e: ECElementEvent) => {
|
||||
if (!e.zrByTouch) {
|
||||
@@ -805,7 +816,7 @@ export class HaChartBase extends LitElement {
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
this.clientWidth,
|
||||
this.clientWidth * window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
@@ -945,6 +956,13 @@ export class HaChartBase extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleChartRenderFinished = () => {
|
||||
if (this._shouldResizeChart) {
|
||||
this.chart?.resize();
|
||||
this._shouldResizeChart = false;
|
||||
}
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
|
||||
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import "./ha-chart-base";
|
||||
import type { HaChartBase } from "./ha-chart-base";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { LitElement, html, css } from "lit";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { CallbackDataParams } from "echarts/types/dist/shared";
|
||||
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
|
||||
import { SankeyChart } from "echarts/charts";
|
||||
import type { CallbackDataParams } from "echarts/types/src/util/types";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import "./ha-chart-base";
|
||||
@@ -39,7 +39,7 @@ type ProcessedLink = Link & {
|
||||
|
||||
const OVERFLOW_MARGIN = 5;
|
||||
const FONT_SIZE = 12;
|
||||
const NODE_GAP = 8;
|
||||
const NODE_GAP = 6;
|
||||
const LABEL_DISTANCE = 5;
|
||||
|
||||
@customElement("ha-sankey-chart")
|
||||
@@ -164,6 +164,7 @@ export class HaSankeyChart extends LitElement {
|
||||
lineStyle: {
|
||||
color: "gradient",
|
||||
opacity: 0.4,
|
||||
curveness: 0.5,
|
||||
},
|
||||
layoutIterations: 0,
|
||||
label: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
getNumberFormatOptions,
|
||||
|
||||
@@ -15,8 +15,8 @@ import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import echarts from "../../resources/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
getStatisticMetadata,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { FilterChip } from "@material/web/chips/internal/filter-chip";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip {
|
||||
var(--rgb-primary-text-color),
|
||||
0.15
|
||||
);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -129,7 +129,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
${canMove && isVisible
|
||||
? html`<ha-svg-icon
|
||||
class="handle"
|
||||
.path=${mdiDrag}
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
slot="graphic"
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
|
||||
@@ -5,24 +5,18 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import {
|
||||
computeDeviceName,
|
||||
computeDeviceNameDisplay,
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceContext } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
getDeviceEntityDisplayLookup,
|
||||
type DeviceEntityDisplayLookup,
|
||||
getDevices,
|
||||
type DevicePickerItem,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
device: DeviceRegistryEntry
|
||||
@@ -30,11 +24,6 @@ export type HaDevicePickerDeviceFilterFunc = (
|
||||
|
||||
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
|
||||
|
||||
interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
}
|
||||
|
||||
@customElement("ha-device-picker")
|
||||
export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -104,6 +93,8 @@ export class HaDevicePicker extends LitElement {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(getDevices);
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
this._loadConfigEntries();
|
||||
@@ -117,162 +108,18 @@ export class HaDevicePicker extends LitElement {
|
||||
}
|
||||
|
||||
private _getItems = () =>
|
||||
this._getDevices(
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._getDevicesMemoized(
|
||||
this.hass,
|
||||
this._configEntryLookup,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeDevices
|
||||
this.excludeDevices,
|
||||
this.value
|
||||
);
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
entityFilter: this["entityFilter"],
|
||||
excludeDevices: this["excludeDevices"]
|
||||
): DevicePickerItem[] => {
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
}
|
||||
|
||||
let inputDevices = devices.filter(
|
||||
(device) => device.id === this.value || !device.disabled_by
|
||||
);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
(entity) =>
|
||||
!excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeDevices) {
|
||||
inputDevices = inputDevices.filter(
|
||||
(device) => !excludeDevices!.includes(device.id)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter(stateObj);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
inputDevices = inputDevices.filter(
|
||||
(device) =>
|
||||
// We always want to include the device of the current value
|
||||
device.id === this.value || deviceFilter!(device)
|
||||
);
|
||||
}
|
||||
|
||||
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
|
||||
const deviceName = computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass,
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { area } = getDeviceContext(device, this.hass);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
const domain = configEntry?.domain;
|
||||
const domainName = domain
|
||||
? domainToName(this.hass.localize, domain)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
label: "",
|
||||
primary:
|
||||
deviceName ||
|
||||
this.hass.localize("ui.components.device-picker.unnamed_device"),
|
||||
secondary: areaName,
|
||||
domain: configEntry?.domain,
|
||||
domain_name: domainName,
|
||||
search_labels: [deviceName, areaName, domain, domainName].filter(
|
||||
Boolean
|
||||
) as string[],
|
||||
sorting_label: deviceName || "zzz",
|
||||
};
|
||||
});
|
||||
|
||||
return outputDevices;
|
||||
}
|
||||
);
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
|
||||
const deviceId = value;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mdiDrag } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-sortable";
|
||||
import "./ha-entity-picker";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
|
||||
|
||||
@customElement("ha-entities-picker")
|
||||
class HaEntitiesPicker extends LitElement {
|
||||
@@ -118,7 +118,7 @@ class HaEntitiesPicker extends LitElement {
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class="entity-handle"
|
||||
.path=${mdiDrag}
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
@@ -147,6 +147,7 @@ class HaEntitiesPicker extends LitElement {
|
||||
.createDomains=${this.createDomains}
|
||||
.required=${this.required && !currentEntities.length}
|
||||
@value-changed=${this._addEntity}
|
||||
add-button
|
||||
></ha-entity-picker>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -8,8 +7,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box";
|
||||
import type { HaComboBox } from "../ha-combo-box";
|
||||
|
||||
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
|
||||
|
||||
interface AttributeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@material/mwc-menu/mwc-menu-surface";
|
||||
import { mdiDrag, mdiPlus } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
@@ -20,11 +20,13 @@ import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-combo-box";
|
||||
import type { HaComboBox } from "../ha-combo-box";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-sortable";
|
||||
|
||||
interface EntityNameOption {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
field_label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +43,23 @@ const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
return item.text;
|
||||
}
|
||||
return `___${item.type}___`;
|
||||
};
|
||||
|
||||
const parseOptionValue = (value: string): EntityNameItem => {
|
||||
if (value.startsWith("___") && value.endsWith("___")) {
|
||||
const type = value.slice(3, -3);
|
||||
if (KNOWN_TYPES.has(type)) {
|
||||
return { type: type as EntityNameType };
|
||||
}
|
||||
}
|
||||
return { type: "text", text: value };
|
||||
};
|
||||
|
||||
@customElement("ha-entity-name-picker")
|
||||
export class HaEntityNamePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -68,8 +87,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
private _validOptions = memoizeOne((entityId?: string) => {
|
||||
const options = new Set<string>();
|
||||
private _validTypes = memoizeOne((entityId?: string) => {
|
||||
const options = new Set<string>(["text"]);
|
||||
if (!entityId) {
|
||||
return options;
|
||||
}
|
||||
@@ -101,33 +120,43 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = this._validOptions(entityId);
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
).map<EntityNameOption>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = options.has(name);
|
||||
const isValid = types.has(name);
|
||||
const primary = this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${name}`
|
||||
);
|
||||
const secondary =
|
||||
stateObj && isValid
|
||||
(stateObj && isValid
|
||||
? this.hass.formatEntityName(stateObj, { type: name })
|
||||
: this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
|
||||
) || "-";
|
||||
)) || "-";
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
value: name,
|
||||
field_label: primary,
|
||||
value: formatOptionValue({ type: name }),
|
||||
};
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
private _customNameOption = memoizeOne((text: string) => ({
|
||||
primary: this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
),
|
||||
secondary: `"${text}"`,
|
||||
field_label: text,
|
||||
value: formatOptionValue({ type: "text", text }),
|
||||
}));
|
||||
|
||||
private _formatItem = (item: EntityNameItem) => {
|
||||
if (item.type === "text") {
|
||||
return `"${item.text}"`;
|
||||
@@ -141,9 +170,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const value = this._items;
|
||||
const options = this._getOptions(this.entityId);
|
||||
const validOptions = this._validOptions(this.entityId);
|
||||
const validTypes = this._validTypes(this.entityId);
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
@@ -157,12 +186,11 @@ export class HaEntityNamePicker extends LitElement {
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._value,
|
||||
this._items,
|
||||
(item) => item,
|
||||
(item: EntityNameItem, idx) => {
|
||||
const label = this._formatItem(item);
|
||||
const isValid =
|
||||
item.type === "text" || validOptions.has(item.type);
|
||||
const isValid = validTypes.has(item.type);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@@ -173,7 +201,10 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
<span>${label}</span>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
@@ -207,14 +238,13 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${""}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled || !this.entityId}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.helper=${this.helper}
|
||||
.items=${options}
|
||||
allow-custom-value
|
||||
item-id-path="value"
|
||||
item-value-path="value"
|
||||
item-label-path="primary"
|
||||
item-label-path="field_label"
|
||||
.renderer=${rowRenderer}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
@@ -223,9 +253,20 @@ export class HaEntityNamePicker extends LitElement {
|
||||
</ha-combo-box>
|
||||
</mwc-menu-surface>
|
||||
</div>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _onClosed(ev) {
|
||||
ev.stopPropagation();
|
||||
this._opened = false;
|
||||
@@ -254,13 +295,16 @@ export class HaEntityNamePicker extends LitElement {
|
||||
this._opened = true;
|
||||
}
|
||||
|
||||
private get _value(): EntityNameItem[] {
|
||||
private get _items(): EntityNameItem[] {
|
||||
return this._toItems(this.value);
|
||||
}
|
||||
|
||||
private _toItems = memoizeOne((value?: typeof this.value) => {
|
||||
if (typeof value === "string") {
|
||||
return [{ type: "text", text: value } as const];
|
||||
if (value === "") {
|
||||
return [];
|
||||
}
|
||||
return [{ type: "text", text: value } satisfies EntityNameItem];
|
||||
}
|
||||
return value ? ensureArray(value) : [];
|
||||
});
|
||||
@@ -268,7 +312,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
return "";
|
||||
}
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
@@ -284,20 +328,21 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const options = this._comboBox.items || [];
|
||||
|
||||
const initialItem =
|
||||
this._editIndex != null ? this._value[this._editIndex] : undefined;
|
||||
this._editIndex != null ? this._items[this._editIndex] : undefined;
|
||||
|
||||
const initialValue = initialItem
|
||||
? initialItem.type === "text"
|
||||
? initialItem.text
|
||||
: initialItem.type
|
||||
: "";
|
||||
const initialValue = initialItem ? formatOptionValue(initialItem) : "";
|
||||
|
||||
const filteredItems = this._filterSelectedOptions(options, initialValue);
|
||||
|
||||
if (initialItem?.type === "text" && initialItem.text) {
|
||||
filteredItems.push(this._customNameOption(initialItem.text));
|
||||
}
|
||||
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
this._comboBox.setInputValue(initialValue);
|
||||
} else {
|
||||
this._opened = false;
|
||||
this._comboBox.setInputValue("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,15 +350,16 @@ export class HaEntityNamePicker extends LitElement {
|
||||
options: EntityNameOption[],
|
||||
current?: string
|
||||
) => {
|
||||
const value = this._value;
|
||||
const items = this._items;
|
||||
|
||||
const types = value.map((item) => item.type) as string[];
|
||||
const excludedValues = new Set(
|
||||
items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
const filteredOptions = options.filter(
|
||||
(option) =>
|
||||
!UNIQUE_TYPES.has(option.value) ||
|
||||
!types.includes(option.value) ||
|
||||
option.value === current
|
||||
(option) => !excludedValues.has(option.value) || option.value === current
|
||||
);
|
||||
return filteredOptions;
|
||||
};
|
||||
@@ -324,20 +370,14 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const options = this._comboBox.items || [];
|
||||
|
||||
const currentItem =
|
||||
this._editIndex != null ? this._value[this._editIndex] : undefined;
|
||||
this._editIndex != null ? this._items[this._editIndex] : undefined;
|
||||
|
||||
const currentValue = currentItem
|
||||
? currentItem.type === "text"
|
||||
? currentItem.text
|
||||
: currentItem.type
|
||||
: "";
|
||||
const currentValue = currentItem ? formatOptionValue(currentItem) : "";
|
||||
|
||||
this._comboBox.filteredItems = this._filterSelectedOptions(
|
||||
options,
|
||||
currentValue
|
||||
);
|
||||
let filteredItems = this._filterSelectedOptions(options, currentValue);
|
||||
|
||||
if (!filter) {
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,16 +389,16 @@ export class HaEntityNamePicker extends LitElement {
|
||||
ignoreDiacritics: true,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
|
||||
const filteredItems = fuse.search(filter).map((result) => result.item);
|
||||
|
||||
const fuse = new Fuse(filteredItems, fuseOptions);
|
||||
filteredItems = fuse.search(filter).map((result) => result.item);
|
||||
filteredItems.push(this._customNameOption(input));
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const value = this._value;
|
||||
const value = this._items;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
@@ -369,7 +409,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
private async _removeItem(ev) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this._value];
|
||||
const value = [...this._items];
|
||||
const idx = parseInt(ev.target.dataset.idx, 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
@@ -385,11 +425,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: EntityNameItem = KNOWN_TYPES.has(value as any)
|
||||
? { type: value as EntityNameType }
|
||||
: { type: "text", text: value };
|
||||
const item: EntityNameItem = parseOptionValue(value);
|
||||
|
||||
const newValue = [...this._value];
|
||||
const newValue = [...this._items];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = item;
|
||||
@@ -483,6 +521,11 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
|
||||
import {
|
||||
getEntities,
|
||||
type EntityComboBoxItem,
|
||||
} from "../../data/entity_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
isHelperDomain,
|
||||
@@ -20,21 +22,11 @@ import type { HomeAssistant } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
|
||||
interface EntityComboBoxItem extends PickerComboBoxItem {
|
||||
domain_name?: string;
|
||||
stateObj?: HassEntity;
|
||||
}
|
||||
|
||||
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
|
||||
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@customElement("ha-entity-picker")
|
||||
@@ -121,6 +113,9 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@property({ attribute: "add-button", type: Boolean })
|
||||
public addButton = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||
@@ -255,8 +250,10 @@ export class HaEntityPicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getEntities(
|
||||
this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
@@ -264,128 +261,10 @@ export class HaEntityPicker extends LitElement {
|
||||
this.includeDeviceClasses,
|
||||
this.includeUnitOfMeasurement,
|
||||
this.includeEntities,
|
||||
this.excludeEntities
|
||||
this.excludeEntities,
|
||||
this.value
|
||||
);
|
||||
|
||||
private _getEntities = memoizeOne(
|
||||
(
|
||||
hass: this["hass"],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
entityFilter: this["entityFilter"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
|
||||
includeEntities: this["includeEntities"],
|
||||
excludeEntities: this["excludeEntities"]
|
||||
): EntityComboBoxItem[] => {
|
||||
let items: EntityComboBoxItem[] = [];
|
||||
|
||||
let entityIds = Object.keys(hass.states);
|
||||
|
||||
if (includeEntities) {
|
||||
entityIds = entityIds.filter((entityId) =>
|
||||
includeEntities.includes(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeEntities) {
|
||||
entityIds = entityIds.filter(
|
||||
(entityId) => !excludeEntities.includes(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDomains) {
|
||||
entityIds = entityIds.filter((eid) =>
|
||||
includeDomains.includes(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
entityIds = entityIds.filter(
|
||||
(eid) => !excludeDomains.includes(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(hass);
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domainName = domainToName(hass.localize, computeDomain(entityId));
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
|
||||
|
||||
return {
|
||||
id: entityId,
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
domain_name: domainName,
|
||||
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
|
||||
search_labels: [
|
||||
entityName,
|
||||
deviceName,
|
||||
areaName,
|
||||
domainName,
|
||||
friendlyName,
|
||||
entityId,
|
||||
].filter(Boolean) as string[],
|
||||
a11y_label: a11yLabel,
|
||||
stateObj: stateObj,
|
||||
};
|
||||
});
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === this.value ||
|
||||
(item.stateObj?.attributes.device_class &&
|
||||
includeDeviceClasses.includes(
|
||||
item.stateObj.attributes.device_class
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeUnitOfMeasurement) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === this.value ||
|
||||
(item.stateObj?.attributes.unit_of_measurement &&
|
||||
includeUnitOfMeasurement.includes(
|
||||
item.stateObj.attributes.unit_of_measurement
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === this.value ||
|
||||
(item.stateObj && entityFilter!(item.stateObj))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
@@ -405,7 +284,7 @@ export class HaEntityPicker extends LitElement {
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${notFoundLabel}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.value=${this.addButton ? undefined : this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
@@ -413,6 +292,9 @@ export class HaEntityPicker extends LitElement {
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
.addButtonLabel=${this.addButton
|
||||
? this.hass.localize("ui.components.entity.entity-picker.add")
|
||||
: undefined}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import { mdiDrag } from "@mdi/js";
|
||||
import "@material/mwc-menu/mwc-menu-surface";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import {
|
||||
STATE_DISPLAY_SPECIAL_CONTENT,
|
||||
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
|
||||
} from "../../state-display/state-display";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box";
|
||||
import "../ha-sortable";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-combo-box";
|
||||
import type { HaComboBox } from "../ha-combo-box";
|
||||
import "../ha-sortable";
|
||||
|
||||
interface StateContentOption {
|
||||
primary: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const rowRenderer: ComboBoxLitRenderer<StateContentOption> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
<span slot="headline">${item.primary}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const HIDDEN_ATTRIBUTES = [
|
||||
"access_token",
|
||||
@@ -74,7 +90,7 @@ const HIDDEN_ATTRIBUTES = [
|
||||
];
|
||||
|
||||
@customElement("ha-entity-state-content-picker")
|
||||
class HaEntityStatePicker extends LitElement {
|
||||
export class HaStateContentPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public entityId?: string;
|
||||
@@ -95,26 +111,28 @@ class HaEntityStatePicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state() private _opened = false;
|
||||
@query(".container", true) private _container?: HTMLDivElement;
|
||||
|
||||
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues) {
|
||||
return !(!changedProps.has("_opened") && this._opened);
|
||||
}
|
||||
@state() private _opened = false;
|
||||
|
||||
private options = memoizeOne(
|
||||
private _editIndex?: number;
|
||||
|
||||
private _options = memoizeOne(
|
||||
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
|
||||
const domain = entityId ? computeDomain(entityId) : undefined;
|
||||
return [
|
||||
{
|
||||
label: this.hass.localize("ui.components.state-content-picker.state"),
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.state"
|
||||
),
|
||||
value: "state",
|
||||
},
|
||||
...(allowName
|
||||
? [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.name"
|
||||
),
|
||||
value: "name",
|
||||
@@ -122,13 +140,13 @@ class HaEntityStatePicker extends LitElement {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: this.hass.localize(
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.last_changed"
|
||||
),
|
||||
value: "last_changed",
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.last_updated"
|
||||
),
|
||||
value: "last_updated",
|
||||
@@ -137,7 +155,7 @@ class HaEntityStatePicker extends LitElement {
|
||||
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
|
||||
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
|
||||
).map((content) => ({
|
||||
label: this.hass.localize(
|
||||
primary: this.hass.localize(
|
||||
`ui.components.state-content-picker.${content}`
|
||||
),
|
||||
value: content,
|
||||
@@ -146,105 +164,201 @@ class HaEntityStatePicker extends LitElement {
|
||||
...Object.keys(stateObj?.attributes ?? {})
|
||||
.filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
|
||||
.map((attribute) => ({
|
||||
primary: this.hass.formatEntityAttributeName(stateObj!, attribute),
|
||||
value: attribute,
|
||||
label: this.hass.formatEntityAttributeName(stateObj!, attribute),
|
||||
})),
|
||||
];
|
||||
] satisfies StateContentOption[];
|
||||
}
|
||||
);
|
||||
|
||||
private _filter = "";
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const value = this._value;
|
||||
|
||||
const stateObj = this.entityId
|
||||
? this.hass.states[this.entityId]
|
||||
: undefined;
|
||||
|
||||
const options = this.options(this.entityId, stateObj, this.allowName);
|
||||
const optionItems = options.filter(
|
||||
(option) => !this._value.includes(option.value)
|
||||
);
|
||||
const options = this._options(this.entityId, stateObj, this.allowName);
|
||||
|
||||
return html`
|
||||
${value?.length
|
||||
? html`
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._value,
|
||||
(item) => item,
|
||||
(item, idx) => {
|
||||
const label =
|
||||
options.find((option) => option.value === item)?.label ||
|
||||
item;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
.label=${label}
|
||||
selected
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
|
||||
${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
`
|
||||
: nothing}
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<div class="container ${this.disabled ? "disabled" : ""}">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._value,
|
||||
(item) => item,
|
||||
(item: string, idx) => {
|
||||
const label = options.find((o) => o.value === item)?.primary;
|
||||
const isValid = !!label;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label || item}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this.hass.localize(
|
||||
"ui.components.entity.entity-state-content-picker.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
|
||||
<ha-combo-box
|
||||
item-value-path="value"
|
||||
item-label-path="label"
|
||||
.hass=${this.hass}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${""}
|
||||
.items=${optionItems}
|
||||
allow-custom-value
|
||||
@filter-changed=${this._filterChanged}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
@opened-changed=${this._openedChanged}
|
||||
></ha-combo-box>
|
||||
<mwc-menu-surface
|
||||
.open=${this._opened}
|
||||
@closed=${this._onClosed}
|
||||
@opened=${this._onOpened}
|
||||
@input=${stopPropagation}
|
||||
.anchor=${this._container}
|
||||
>
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.value=${""}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled || !this.entityId}
|
||||
.required=${this.required && !value.length}
|
||||
.helper=${this.helper}
|
||||
.items=${options}
|
||||
allow-custom-value
|
||||
item-id-path="value"
|
||||
item-value-path="value"
|
||||
item-label-path="primary"
|
||||
.renderer=${rowRenderer}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._comboBoxValueChanged}
|
||||
@filter-changed=${this._filterChanged}
|
||||
>
|
||||
</ha-combo-box>
|
||||
</mwc-menu-surface>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _onClosed(ev) {
|
||||
ev.stopPropagation();
|
||||
this._opened = false;
|
||||
this._editIndex = undefined;
|
||||
}
|
||||
|
||||
private async _onOpened(ev) {
|
||||
if (!this._opened) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._opened = true;
|
||||
await this._comboBox?.focus();
|
||||
await this._comboBox?.open();
|
||||
}
|
||||
|
||||
private async _addItem(ev) {
|
||||
ev.stopPropagation();
|
||||
this._opened = true;
|
||||
}
|
||||
|
||||
private async _editItem(ev) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(ev.currentTarget.dataset.idx, 10);
|
||||
this._editIndex = idx;
|
||||
this._opened = true;
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return !this.value ? [] : ensureArray(this.value);
|
||||
}
|
||||
|
||||
private _toValue = memoizeOne((value: string[]): typeof this.value => {
|
||||
if (value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.length === 1) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
this._comboBox.filteredItems = this._comboBox.items;
|
||||
const open = ev.detail.value;
|
||||
if (open) {
|
||||
const options = this._comboBox.items || [];
|
||||
|
||||
const initialValue =
|
||||
this._editIndex != null ? this._value[this._editIndex] : "";
|
||||
const filteredItems = this._filterSelectedOptions(options, initialValue);
|
||||
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
this._comboBox.setInputValue(initialValue);
|
||||
} else {
|
||||
this._opened = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _filterChanged(ev?: CustomEvent): void {
|
||||
this._filter = ev?.detail.value || "";
|
||||
private _filterSelectedOptions = (
|
||||
options: StateContentOption[],
|
||||
current?: string
|
||||
) => {
|
||||
const value = this._value;
|
||||
|
||||
const filteredItems = this._comboBox.items?.filter((item) => {
|
||||
const label = item.label || item.value;
|
||||
return label.toLowerCase().includes(this._filter?.toLowerCase());
|
||||
});
|
||||
return options.filter(
|
||||
(option) => !value.includes(option.value) || option.value === current
|
||||
);
|
||||
};
|
||||
|
||||
if (this._filter) {
|
||||
filteredItems?.unshift({ label: this._filter, value: this._filter });
|
||||
private _filterChanged(ev: ValueChangedEvent<string>) {
|
||||
const input = ev.detail.value;
|
||||
const filter = input?.toLowerCase() || "";
|
||||
const options = this._comboBox.items || [];
|
||||
|
||||
const currentValue =
|
||||
this._editIndex != null ? this._value[this._editIndex] : "";
|
||||
|
||||
this._comboBox.filteredItems = this._filterSelectedOptions(
|
||||
options,
|
||||
currentValue
|
||||
);
|
||||
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fuseOptions: IFuseOptions<StateContentOption> = {
|
||||
keys: ["primary", "secondary", "value"],
|
||||
isCaseSensitive: false,
|
||||
minMatchCharLength: Math.min(filter.length, 2),
|
||||
threshold: 0.2,
|
||||
ignoreDiacritics: true,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
|
||||
const filteredItems = fuse.search(filter).map((result) => result.item);
|
||||
|
||||
this._comboBox.filteredItems = filteredItems;
|
||||
}
|
||||
|
||||
@@ -257,43 +371,40 @@ class HaEntityStatePicker extends LitElement {
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
await this.updateComplete;
|
||||
this._filterChanged();
|
||||
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
|
||||
}
|
||||
|
||||
private async _removeItem(ev) {
|
||||
ev.stopPropagation();
|
||||
const value: string[] = [...this._value];
|
||||
value.splice(ev.target.idx, 1);
|
||||
const value = [...this._value];
|
||||
const idx = parseInt(ev.target.dataset.idx, 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
await this.updateComplete;
|
||||
this._filterChanged();
|
||||
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
|
||||
}
|
||||
|
||||
private _comboBoxValueChanged(ev: CustomEvent): void {
|
||||
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const newValue = ev.detail.value;
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || newValue === "") {
|
||||
if (this.disabled || value === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = this._value;
|
||||
const newValue = [...this._value];
|
||||
|
||||
if (currentValue.includes(newValue)) {
|
||||
return;
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = value;
|
||||
} else {
|
||||
newValue.push(value);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this._filterChanged();
|
||||
this._comboBox.setInputValue("");
|
||||
}, 0);
|
||||
|
||||
this._setValue([...currentValue, newValue]);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
const newValue =
|
||||
value.length === 0 ? undefined : value.length === 1 ? value[0] : value;
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
@@ -303,10 +414,64 @@ class HaEntityStatePicker extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.container:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
transform:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
.container.disabled:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
.container:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
mwc-menu-surface {
|
||||
--mdc-menu-min-width: 100%;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: 8px 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
@@ -326,6 +491,6 @@ class HaEntityStatePicker extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-entity-state-content-picker": HaEntityStatePicker;
|
||||
"ha-entity-state-content-picker": HaStateContentPicker;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -9,8 +8,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box";
|
||||
import type { HaComboBox } from "../ha-combo-box";
|
||||
|
||||
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
|
||||
|
||||
interface StateOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement {
|
||||
</span>
|
||||
<ha-switch
|
||||
@change=${this._handleRowClick}
|
||||
.checked=${baseEnabled}
|
||||
.checked=${!!baseEnabled}
|
||||
.preference=${"base"}
|
||||
.disabled=${loading}
|
||||
name="base"
|
||||
@@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement {
|
||||
<ha-switch
|
||||
.id="switch-${preference}"
|
||||
@change=${this._handleRowClick}
|
||||
.checked=${this.analytics?.preferences[preference]}
|
||||
.checked=${!!this.analytics?.preferences[preference]}
|
||||
.preference=${preference}
|
||||
name=${preference}
|
||||
>
|
||||
@@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement {
|
||||
</span>
|
||||
<ha-switch
|
||||
@change=${this._handleRowClick}
|
||||
.checked=${this.analytics?.preferences.diagnostics}
|
||||
.checked=${!!this.analytics?.preferences.diagnostics}
|
||||
.preference=${"diagnostics"}
|
||||
.disabled=${loading}
|
||||
name="diagnostics"
|
||||
|
||||
@@ -8,21 +8,13 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../data/area_registry";
|
||||
import type {
|
||||
DeviceEntityDisplayLookup,
|
||||
DeviceRegistryEntry,
|
||||
} from "../data/device_registry";
|
||||
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
|
||||
import {
|
||||
getFloorAreaLookup,
|
||||
type FloorRegistryEntry,
|
||||
} from "../data/floor_registry";
|
||||
getAreasAndFloors,
|
||||
type AreaFloorValue,
|
||||
type FloorComboBoxItem,
|
||||
} from "../data/area_floor";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-combo-box-item";
|
||||
@@ -30,24 +22,12 @@ import "./ha-floor-icon";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-icon-button";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
|
||||
interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
type: "floor" | "area";
|
||||
floor?: FloorRegistryEntry;
|
||||
area?: AreaRegistryEntry;
|
||||
}
|
||||
|
||||
interface AreaFloorValue {
|
||||
id: string;
|
||||
type: "floor" | "area";
|
||||
}
|
||||
|
||||
@customElement("ha-area-floor-picker")
|
||||
export class HaAreaFloorPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -154,243 +134,6 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _getAreasAndFloors = memoizeOne(
|
||||
(
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
entityFilter: this["entityFilter"],
|
||||
excludeAreas: this["excludeAreas"],
|
||||
excludeFloors: this["excludeFloors"]
|
||||
): FloorComboBoxItem[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
deviceFilter ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
inputDevices = devices;
|
||||
inputEntities = entities.filter((entity) => entity.area_id);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
(entity) =>
|
||||
!excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter(
|
||||
(entity) =>
|
||||
!excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
inputDevices = inputDevices!.filter((device) =>
|
||||
deviceFilter!(device)
|
||||
);
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter(stateObj);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter!(stateObj);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let outputAreas = areas;
|
||||
|
||||
let areaIds: string[] | undefined;
|
||||
|
||||
if (inputDevices) {
|
||||
areaIds = inputDevices
|
||||
.filter((device) => device.area_id)
|
||||
.map((device) => device.area_id!);
|
||||
}
|
||||
|
||||
if (inputEntities) {
|
||||
areaIds = (areaIds ?? []).concat(
|
||||
inputEntities
|
||||
.filter((entity) => entity.area_id)
|
||||
.map((entity) => entity.area_id!)
|
||||
);
|
||||
}
|
||||
|
||||
if (areaIds) {
|
||||
outputAreas = outputAreas.filter((area) =>
|
||||
areaIds!.includes(area.area_id)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeAreas) {
|
||||
outputAreas = outputAreas.filter(
|
||||
(area) => !excludeAreas!.includes(area.area_id)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeFloors) {
|
||||
outputAreas = outputAreas.filter(
|
||||
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
|
||||
);
|
||||
}
|
||||
|
||||
const floorAreaLookup = getFloorAreaLookup(outputAreas);
|
||||
const unassisgnedAreas = Object.values(outputAreas).filter(
|
||||
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const floorAreaEntries: [
|
||||
FloorRegistryEntry | undefined,
|
||||
AreaRegistryEntry[],
|
||||
][] = Object.entries(floorAreaLookup)
|
||||
.map(([floorId, floorAreas]) => {
|
||||
const floor = floors.find((fl) => fl.floor_id === floorId)!;
|
||||
return [floor, floorAreas] as const;
|
||||
})
|
||||
.sort(([floorA], [floorB]) => {
|
||||
if (floorA.level !== floorB.level) {
|
||||
return (floorA.level ?? 0) - (floorB.level ?? 0);
|
||||
}
|
||||
return stringCompare(floorA.name, floorB.name);
|
||||
});
|
||||
|
||||
const items: FloorComboBoxItem[] = [];
|
||||
|
||||
floorAreaEntries.forEach(([floor, floorAreas]) => {
|
||||
if (floor) {
|
||||
const floorName = computeFloorName(floor);
|
||||
|
||||
const areaSearchLabels = floorAreas
|
||||
.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return [area.area_id, areaName, ...area.aliases];
|
||||
})
|
||||
.flat();
|
||||
|
||||
items.push({
|
||||
id: this._formatValue({ id: floor.floor_id, type: "floor" }),
|
||||
type: "floor",
|
||||
primary: floorName,
|
||||
floor: floor,
|
||||
search_labels: [
|
||||
floor.floor_id,
|
||||
floorName,
|
||||
...floor.aliases,
|
||||
...areaSearchLabels,
|
||||
],
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
...floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: this._formatValue({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
items.push(
|
||||
...unassisgnedAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: this._formatValue({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
|
||||
item,
|
||||
{ index },
|
||||
@@ -445,12 +188,16 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getAreasAndFloors(
|
||||
this._getAreasAndFloorsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._formatValue,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
|
||||
@@ -107,7 +107,7 @@ export class HaAreaPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const { floor } = getAreaContext(area, this.hass);
|
||||
const { floor } = getAreaContext(area, this.hass.floors);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
@@ -279,7 +279,7 @@ export class HaAreaPicker extends LitElement {
|
||||
}
|
||||
|
||||
const items = outputAreas.map<PickerComboBoxItem>((area) => {
|
||||
const { floor } = getAreaContext(area, this.hass);
|
||||
const { floor } = getAreaContext(area, this.hass.floors);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = computeAreaName(area);
|
||||
return {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement {
|
||||
);
|
||||
|
||||
const items: DisplayItem[] = areas.map((area) => {
|
||||
const { floor } = getAreaContext(area, this.hass!);
|
||||
const { floor } = getAreaContext(area, this.hass.floors);
|
||||
return {
|
||||
value: area.area_id,
|
||||
label: area.name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiDrag, mdiTextureBox } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
@@ -105,7 +105,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
|
||||
<ha-svg-icon
|
||||
class="handle"
|
||||
slot="icons"
|
||||
.path=${mdiDrag}
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
`}
|
||||
<ha-items-display-editor
|
||||
@@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
|
||||
);
|
||||
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
|
||||
(acc, area) => {
|
||||
const { floor } = getAreaContext(area, this.hass!);
|
||||
const { floor } = getAreaContext(area, this.hass.floors);
|
||||
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
|
||||
|
||||
if (!acc[floorId]) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
|
||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
@@ -8,6 +9,9 @@ export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
export class HaBottomSheet extends LitElement {
|
||||
@property({ type: Boolean }) public open = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
|
||||
public flexContent = false;
|
||||
|
||||
@state() private _drawerOpen = false;
|
||||
|
||||
private _handleAfterHide() {
|
||||
@@ -34,37 +38,61 @@ export class HaBottomSheet extends LitElement {
|
||||
@wa-after-hide=${this._handleAfterHide}
|
||||
without-header
|
||||
>
|
||||
<slot></slot>
|
||||
<slot name="header"></slot>
|
||||
<div class="body ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</wa-drawer>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
wa-drawer {
|
||||
--wa-color-surface-raised: var(
|
||||
--ha-bottom-sheet-surface-background,
|
||||
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
|
||||
);
|
||||
--spacing: 0;
|
||||
--size: auto;
|
||||
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
|
||||
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
|
||||
}
|
||||
wa-drawer::part(dialog) {
|
||||
border-top-left-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
);
|
||||
border-top-right-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
);
|
||||
max-height: 90vh;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
wa-drawer {
|
||||
--wa-color-surface-raised: transparent;
|
||||
--spacing: 0;
|
||||
--size: var(--ha-bottom-sheet-height, auto);
|
||||
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
|
||||
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
|
||||
}
|
||||
wa-drawer::part(dialog) {
|
||||
max-height: var(--ha-bottom-sheet-max-height, 90vh);
|
||||
align-items: center;
|
||||
}
|
||||
wa-drawer::part(body) {
|
||||
max-width: var(--ha-bottom-sheet-max-width);
|
||||
width: 100%;
|
||||
border-top-left-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
);
|
||||
border-top-right-radius: var(
|
||||
--ha-bottom-sheet-border-radius,
|
||||
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
|
||||
);
|
||||
background-color: var(
|
||||
--ha-bottom-sheet-surface-background,
|
||||
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
|
||||
);
|
||||
padding: var(
|
||||
--ha-bottom-sheet-padding,
|
||||
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
|
||||
var(--safe-area-inset-left)
|
||||
);
|
||||
}
|
||||
:host([flexcontent]) wa-drawer::part(body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
:host([flexcontent]) .body {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -31,6 +31,9 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
|
||||
public nowrap = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "full-width" })
|
||||
public fullWidth = false;
|
||||
|
||||
@property() public variant:
|
||||
| "brand"
|
||||
| "neutral"
|
||||
@@ -38,6 +41,13 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
| "warning"
|
||||
| "danger" = "brand";
|
||||
|
||||
@property({ attribute: "active-variant" }) public activeVariant?:
|
||||
| "brand"
|
||||
| "neutral"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<wa-button-group childSelector="ha-button">
|
||||
@@ -46,7 +56,9 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
html`<ha-button
|
||||
iconTag="ha-svg-icon"
|
||||
class="icon"
|
||||
.variant=${this.variant}
|
||||
.variant=${this.active !== button.value || !this.activeVariant
|
||||
? this.variant
|
||||
: this.activeVariant}
|
||||
.size=${this.size}
|
||||
.value=${button.value}
|
||||
@click=${this._handleClick}
|
||||
@@ -78,6 +90,19 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
:host([no-wrap]) wa-button-group::part(base) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
wa-button-group {
|
||||
padding: var(--ha-button-toggle-group-padding);
|
||||
}
|
||||
|
||||
:host([full-width]) wa-button-group,
|
||||
:host([full-width]) wa-button-group::part(base) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host([full-width]) ha-button {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ export class HaCameraStream extends LitElement {
|
||||
const streams = this._streams(
|
||||
this._capabilities?.frontend_stream_types,
|
||||
this._hlsStreams,
|
||||
this._webRtcStreams
|
||||
this._webRtcStreams,
|
||||
this.muted
|
||||
);
|
||||
return html`${repeat(
|
||||
streams,
|
||||
@@ -190,7 +191,8 @@ export class HaCameraStream extends LitElement {
|
||||
(
|
||||
supportedTypes?: StreamType[],
|
||||
hlsStreams?: { hasAudio: boolean; hasVideo: boolean },
|
||||
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean }
|
||||
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean },
|
||||
muted?: boolean
|
||||
): Stream[] => {
|
||||
if (__DEMO__) {
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
@@ -220,9 +222,10 @@ export class HaCameraStream extends LitElement {
|
||||
if (
|
||||
hlsStreams.hasVideo &&
|
||||
hlsStreams.hasAudio &&
|
||||
!webRtcStreams.hasAudio
|
||||
!webRtcStreams.hasAudio &&
|
||||
!muted
|
||||
) {
|
||||
// webRTC stream is missing audio, use HLS
|
||||
// webRTC stream is missing audio and audio is not muted, use HLS
|
||||
return [{ type: STREAM_TYPE_HLS, visible: true }];
|
||||
}
|
||||
if (webRtcStreams.hasVideo) {
|
||||
|
||||
@@ -239,6 +239,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror.crosshairCursor(),
|
||||
this._loadedCodeMirror.highlightSelectionMatches(),
|
||||
this._loadedCodeMirror.highlightActiveLine(),
|
||||
this._loadedCodeMirror.dropCursor(),
|
||||
this._loadedCodeMirror.indentationMarkers({
|
||||
thickness: 0,
|
||||
activeThickness: 1,
|
||||
|
||||
@@ -6,6 +6,9 @@ export class HaDialogHeader extends LitElement {
|
||||
@property({ type: String, attribute: "subtitle-position" })
|
||||
public subtitlePosition: "above" | "below" = "below";
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "show-border" })
|
||||
public showBorder = false;
|
||||
|
||||
protected render() {
|
||||
const titleSlot = html`<div class="header-title">
|
||||
<slot name="title"></slot>
|
||||
@@ -49,12 +52,16 @@ export class HaDialogHeader extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
padding: 0 var(--ha-space-1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header-content {
|
||||
flex: 1;
|
||||
padding: 10px 4px;
|
||||
padding: 10px var(--ha-space-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: var(--ha-space-12);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -63,7 +70,7 @@ export class HaDialogHeader extends LitElement {
|
||||
.header-title {
|
||||
height: var(
|
||||
--ha-dialog-header-title-height,
|
||||
calc(var(--ha-font-size-xl) + 4px)
|
||||
calc(var(--ha-font-size-xl) + var(--ha-space-1))
|
||||
);
|
||||
font-size: var(--ha-font-size-xl);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
@@ -76,19 +83,19 @@ export class HaDialogHeader extends LitElement {
|
||||
}
|
||||
@media all and (min-width: 450px) and (min-height: 500px) {
|
||||
.header-bar {
|
||||
padding: 16px;
|
||||
padding: 0 var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
.header-navigation-icon {
|
||||
flex: none;
|
||||
min-width: 8px;
|
||||
min-width: var(--ha-space-2);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.header-action-items {
|
||||
flex: none;
|
||||
min-width: 8px;
|
||||
min-width: var(--ha-space-2);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -49,6 +49,7 @@ export class HaExpansionPanel extends LitElement {
|
||||
tabindex=${this.noCollapse ? -1 : 0}
|
||||
aria-expanded=${this.expanded}
|
||||
aria-controls="sect1"
|
||||
part="summary"
|
||||
>
|
||||
${this.leftChevron ? chevronIcon : nothing}
|
||||
<slot name="leading-icon"></slot>
|
||||
@@ -170,6 +171,11 @@ export class HaExpansionPanel extends LitElement {
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
|
||||
#summary:focus-visible ha-svg-icon.summary-icon {
|
||||
background-color: var(--ha-color-fill-neutral-normal-active);
|
||||
}
|
||||
|
||||
:host([left-chevron]) .summary-icon,
|
||||
|
||||
@@ -248,7 +248,7 @@ export class HaFilterDevices extends LitElement {
|
||||
}
|
||||
search-input-outlined {
|
||||
display: block;
|
||||
padding: 0 8px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement {
|
||||
}
|
||||
search-input-outlined {
|
||||
display: block;
|
||||
padding: 0 8px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement {
|
||||
}
|
||||
search-input-outlined {
|
||||
display: block;
|
||||
padding: 0 8px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement {
|
||||
}
|
||||
search-input-outlined {
|
||||
display: block;
|
||||
padding: 0 8px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
search-input-outlined {
|
||||
display: block;
|
||||
padding: 0 8px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-bottom-sheet";
|
||||
import "./ha-button";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-input-helper-text";
|
||||
@@ -15,7 +19,7 @@ import type {
|
||||
PickerComboBoxSearchFn,
|
||||
} from "./ha-picker-combo-box";
|
||||
import "./ha-picker-field";
|
||||
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
|
||||
import type { PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-generic-picker")
|
||||
@@ -53,7 +57,7 @@ export class HaGenericPicker extends LitElement {
|
||||
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
|
||||
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public valueRenderer?: PickerValueRenderer;
|
||||
@@ -64,58 +68,130 @@ export class HaGenericPicker extends LitElement {
|
||||
@property({ attribute: "not-found-label", type: String })
|
||||
public notFoundLabel?: string;
|
||||
|
||||
@query("ha-picker-field") private _field?: HaPickerField;
|
||||
/** If set picker shows an add button instead of textbox when value isn't set */
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@query(".container") private _containerElement?: HTMLDivElement;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@state() private _pickerWrapperOpen = false;
|
||||
|
||||
@state() private _popoverWidth = 0;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
private _narrow = false;
|
||||
|
||||
// helper to set new value after closing picker, to avoid flicker
|
||||
private _newValue?: string;
|
||||
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.label
|
||||
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
|
||||
: nothing}
|
||||
<div class="container">
|
||||
${!this._opened
|
||||
<div id="picker">
|
||||
<slot name="field">
|
||||
${this.addButtonLabel && !this.value
|
||||
? html`<ha-button
|
||||
size="small"
|
||||
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
|
||||
aria-label=${ifDefined(this.label)}
|
||||
@click=${this.open}
|
||||
@clear=${this._clear}
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${this.valueRenderer}
|
||||
>
|
||||
</ha-picker-field>`}
|
||||
</slot>
|
||||
</div>
|
||||
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
|
||||
? html`
|
||||
<ha-picker-field
|
||||
type="button"
|
||||
compact
|
||||
aria-label=${ifDefined(this.label)}
|
||||
@click=${this.open}
|
||||
@clear=${this._clear}
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${this.valueRenderer}
|
||||
<wa-popover
|
||||
.open=${this._pickerWrapperOpen}
|
||||
style="--body-width: ${this._popoverWidth}px;"
|
||||
without-arrow
|
||||
distance="-4"
|
||||
placement="bottom-start"
|
||||
for="picker"
|
||||
auto-size="vertical"
|
||||
auto-size-padding="16"
|
||||
@wa-after-show=${this._dialogOpened}
|
||||
@wa-after-hide=${this._hidePicker}
|
||||
trap-focus
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
>
|
||||
</ha-picker-field>
|
||||
${this._renderComboBox()}
|
||||
</wa-popover>
|
||||
`
|
||||
: html`
|
||||
<ha-picker-combo-box
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel ??
|
||||
this.hass.localize("ui.common.search")}
|
||||
.value=${this.value}
|
||||
hide-clear-icon
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._valueChanged}
|
||||
.rowRenderer=${this.rowRenderer}
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.getItems=${this.getItems}
|
||||
.getAdditionalItems=${this.getAdditionalItems}
|
||||
.searchFn=${this.searchFn}
|
||||
></ha-picker-combo-box>
|
||||
`}
|
||||
: this._pickerWrapperOpen || this._opened
|
||||
? html`<ha-bottom-sheet
|
||||
flexcontent
|
||||
.open=${this._pickerWrapperOpen}
|
||||
@wa-after-show=${this._dialogOpened}
|
||||
@closed=${this._hidePicker}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
>
|
||||
${this._renderComboBox(true)}
|
||||
</ha-bottom-sheet>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderComboBox(dialogMode = false) {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-picker-combo-box
|
||||
.hass=${this.hass}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel ?? this.hass.localize("ui.common.search")}
|
||||
.value=${this.value}
|
||||
@value-changed=${this._valueChanged}
|
||||
.rowRenderer=${this.rowRenderer}
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.getItems=${this.getItems}
|
||||
.getAdditionalItems=${this.getAdditionalItems}
|
||||
.searchFn=${this.searchFn}
|
||||
.mode=${dialogMode ? "dialog" : "popover"}
|
||||
></ha-picker-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
@@ -124,13 +200,33 @@ export class HaGenericPicker extends LitElement {
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _dialogOpened = () => {
|
||||
this._opened = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._comboBox?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
private _hidePicker(ev) {
|
||||
ev.stopPropagation();
|
||||
if (this._newValue) {
|
||||
fireEvent(this, "value-changed", { value: this._newValue });
|
||||
this._newValue = undefined;
|
||||
}
|
||||
|
||||
this._opened = false;
|
||||
this._pickerWrapperOpen = false;
|
||||
this._unsubscribeTinyKeys?.();
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value });
|
||||
this._pickerWrapperOpen = false;
|
||||
this._newValue = value;
|
||||
}
|
||||
|
||||
private _clear(e) {
|
||||
@@ -143,25 +239,45 @@ export class HaGenericPicker extends LitElement {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
public async open() {
|
||||
public async open(ev?: Event) {
|
||||
ev?.stopPropagation();
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
this._opened = true;
|
||||
await this.updateComplete;
|
||||
this._comboBox?.focus();
|
||||
this._comboBox?.open();
|
||||
this._openedNarrow = this._narrow;
|
||||
this._popoverWidth = this._containerElement?.offsetWidth || 250;
|
||||
this._pickerWrapperOpen = true;
|
||||
this._unsubscribeTinyKeys = tinykeys(this, {
|
||||
Escape: this._handleEscClose,
|
||||
});
|
||||
}
|
||||
|
||||
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
|
||||
const opened = ev.detail.value;
|
||||
if (this._opened && !opened) {
|
||||
this._opened = false;
|
||||
await this.updateComplete;
|
||||
this._field?.focus();
|
||||
}
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._handleResize();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
this._unsubscribeTinyKeys?.();
|
||||
}
|
||||
|
||||
private _handleResize = () => {
|
||||
this._narrow =
|
||||
window.matchMedia("(max-width: 870px)").matches ||
|
||||
window.matchMedia("(max-height: 500px)").matches;
|
||||
|
||||
if (!this._openedNarrow && this._pickerWrapperOpen) {
|
||||
this._popoverWidth = this._containerElement?.offsetWidth || 250;
|
||||
}
|
||||
};
|
||||
|
||||
private _handleEscClose = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
@@ -178,7 +294,45 @@ export class HaGenericPicker extends LitElement {
|
||||
}
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: var(--ha-space-0);
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
width: max(var(--body-width), 250px);
|
||||
max-width: max(var(--body-width), 250px);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
ha-bottom-sheet {
|
||||
--ha-bottom-sheet-height: 90vh;
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
|
||||
--ha-bottom-sheet-max-width: 600px;
|
||||
--ha-bottom-sheet-padding: var(--ha-space-0);
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
|
||||
}
|
||||
|
||||
ha-picker-field.opened {
|
||||
--mdc-text-field-idle-line-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -178,7 +178,7 @@ export class HaItemDisplayEditor extends LitElement {
|
||||
? this._dragHandleKeydown
|
||||
: undefined}
|
||||
class="handle"
|
||||
.path=${mdiDrag}
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
|
||||
@@ -2,19 +2,19 @@ import { mdiLabel, mdiPlus } from "@mdi/js";
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAssignedElements,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type {
|
||||
DeviceEntityDisplayLookup,
|
||||
DeviceRegistryEntry,
|
||||
} from "../data/device_registry";
|
||||
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
|
||||
import type { LabelRegistryEntry } from "../data/label_registry";
|
||||
import {
|
||||
createLabelRegistryEntry,
|
||||
getLabels,
|
||||
subscribeLabelRegistry,
|
||||
} from "../data/label_registry";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
@@ -90,6 +90,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _labels?: LabelRegistryEntry[];
|
||||
|
||||
@queryAssignedElements({ flatten: true })
|
||||
private _slotNodes?: NodeListOf<HTMLElement>;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
public async open() {
|
||||
@@ -137,201 +140,22 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
);
|
||||
|
||||
private _getLabels = memoizeOne(
|
||||
(
|
||||
labels: LabelRegistryEntry[] | undefined,
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
entityFilter: this["entityFilter"],
|
||||
excludeLabels: this["excludeLabels"]
|
||||
): PickerComboBoxItem[] => {
|
||||
if (!labels || labels.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: NO_LABELS,
|
||||
primary: this.hass.localize("ui.components.label-picker.no_labels"),
|
||||
icon_path: mdiLabel,
|
||||
},
|
||||
];
|
||||
}
|
||||
private _getLabelsMemoized = memoizeOne(getLabels);
|
||||
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
deviceFilter ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
inputDevices = devices;
|
||||
inputEntities = entities.filter((entity) => entity.labels.length > 0);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
(entity) =>
|
||||
!excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter(
|
||||
(entity) =>
|
||||
!excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
inputDevices = inputDevices!.filter((device) =>
|
||||
deviceFilter!(device)
|
||||
);
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter(stateObj);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = this.hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter!(stateObj);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let outputLabels = labels;
|
||||
const usedLabels = new Set<string>();
|
||||
|
||||
let areaIds: string[] | undefined;
|
||||
|
||||
if (inputDevices) {
|
||||
areaIds = inputDevices
|
||||
.filter((device) => device.area_id)
|
||||
.map((device) => device.area_id!);
|
||||
|
||||
inputDevices.forEach((device) => {
|
||||
device.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
if (inputEntities) {
|
||||
areaIds = (areaIds ?? []).concat(
|
||||
inputEntities
|
||||
.filter((entity) => entity.area_id)
|
||||
.map((entity) => entity.area_id!)
|
||||
);
|
||||
inputEntities.forEach((entity) => {
|
||||
entity.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
if (areaIds) {
|
||||
areaIds.forEach((areaId) => {
|
||||
const area = haAreas[areaId];
|
||||
area.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeLabels) {
|
||||
outputLabels = outputLabels.filter(
|
||||
(label) => !excludeLabels!.includes(label.label_id)
|
||||
);
|
||||
}
|
||||
|
||||
if (inputDevices || inputEntities) {
|
||||
outputLabels = outputLabels.filter((label) =>
|
||||
usedLabels.has(label.label_id)
|
||||
);
|
||||
}
|
||||
|
||||
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
|
||||
id: label.label_id,
|
||||
primary: label.name,
|
||||
icon: label.icon || undefined,
|
||||
icon_path: label.icon ? undefined : mdiLabel,
|
||||
sorting_label: label.name,
|
||||
search_labels: [label.name, label.label_id, label.description].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
),
|
||||
}));
|
||||
|
||||
return items;
|
||||
private _getItems = () => {
|
||||
if (!this._labels || this._labels.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: NO_LABELS,
|
||||
primary: this.hass.localize("ui.components.label-picker.no_labels"),
|
||||
icon_path: mdiLabel,
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getLabels(
|
||||
return this._getLabelsMemoized(
|
||||
this.hass,
|
||||
this._labels,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
@@ -339,6 +163,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
this.entityFilter,
|
||||
this.excludeLabels
|
||||
);
|
||||
};
|
||||
|
||||
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
|
||||
if (!labels) {
|
||||
@@ -395,12 +220,14 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.disabled=${this.disabled}
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${this.label}
|
||||
.notFoundLabel=${this.hass.localize(
|
||||
"ui.components.label-picker.no_match"
|
||||
)}
|
||||
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.getItems=${this._getItems}
|
||||
@@ -408,6 +235,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
.valueRenderer=${valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
<slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
@@ -123,36 +124,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
${labels?.length
|
||||
? html`<ha-chip-set>
|
||||
${repeat(
|
||||
labels,
|
||||
(label) => label?.label_id,
|
||||
(label) => {
|
||||
const color = label?.color
|
||||
? computeCssColor(label.color)
|
||||
: undefined;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.item=${label}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._openDetail}
|
||||
.label=${label?.name}
|
||||
selected
|
||||
style=${color ? `--color: ${color}` : ""}
|
||||
>
|
||||
${label?.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>`
|
||||
: nothing}
|
||||
<ha-label-picker
|
||||
.hass=${this.hass}
|
||||
.helper=${this.helper}
|
||||
@@ -162,6 +133,47 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
|
||||
.excludeLabels=${this.value}
|
||||
@value-changed=${this._labelChanged}
|
||||
>
|
||||
<ha-chip-set>
|
||||
${labels?.length
|
||||
? repeat(
|
||||
labels,
|
||||
(label) => label?.label_id,
|
||||
(label) => {
|
||||
const color = label?.color
|
||||
? computeCssColor(label.color)
|
||||
: undefined;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.item=${label}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._openDetail}
|
||||
.disabled=${this.disabled}
|
||||
.label=${label?.name}
|
||||
selected
|
||||
style=${color ? `--color: ${color}` : ""}
|
||||
>
|
||||
${label?.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)
|
||||
: nothing}
|
||||
<ha-button
|
||||
id="picker"
|
||||
size="small"
|
||||
appearance="filled"
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.label-picker.add")}
|
||||
</ha-button>
|
||||
</ha-chip-set>
|
||||
</ha-label-picker>
|
||||
`;
|
||||
}
|
||||
@@ -203,9 +215,25 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private _openPicker(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.labelPicker.open();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-chip-set {
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--mdc-text-field-fill-color);
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
|
||||
border-top-right-radius: var(--ha-border-radius-sm);
|
||||
border-top-left-radius: var(--ha-border-radius-sm);
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.placeholder {
|
||||
color: var(--mdc-text-field-label-ink-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--ha-space-8);
|
||||
}
|
||||
ha-input-chip {
|
||||
--md-input-chip-selected-container-color: var(--color, var(--grey-color));
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import Fuse from "fuse.js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
query,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { HaFuse } from "../resources/fuse";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-combo-box";
|
||||
import type { HaComboBox } from "./ha-combo-box";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-icon";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
|
||||
export interface PickerComboBoxItem {
|
||||
id: string;
|
||||
@@ -33,10 +42,13 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
|
||||
|
||||
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
|
||||
|
||||
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
|
||||
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
|
||||
item
|
||||
) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
<ha-combo-box-item
|
||||
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
|
||||
compact
|
||||
>
|
||||
${item.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: item.icon_path
|
||||
@@ -73,7 +85,7 @@ export class HaPickerComboBox extends LitElement {
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
@state() private _listScrolled = false;
|
||||
|
||||
@property({ attribute: false, type: Array })
|
||||
public getItems?: () => PickerComboBoxItem[];
|
||||
@@ -82,10 +94,7 @@ export class HaPickerComboBox extends LitElement {
|
||||
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
|
||||
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
|
||||
|
||||
@property({ attribute: "not-found-label", type: String })
|
||||
public notFoundLabel?: string;
|
||||
@@ -93,23 +102,59 @@ export class HaPickerComboBox extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
||||
|
||||
@state() private _opened = false;
|
||||
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
|
||||
|
||||
@query("ha-combo-box", true) public comboBox!: HaComboBox;
|
||||
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.open();
|
||||
@query("ha-textfield") private _searchFieldElement?: HaTextField;
|
||||
|
||||
@state() private _items: PickerComboBoxItemWithLabel[] = [];
|
||||
|
||||
private _allItems: PickerComboBoxItemWithLabel[] = [];
|
||||
|
||||
private _selectedItemIndex = -1;
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
private _removeKeyboardShortcuts?: () => void;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._registerKeyboardShortcuts();
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.focus();
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
this._allItems = this._getItems();
|
||||
this._items = this._allItems;
|
||||
}
|
||||
}
|
||||
|
||||
private _initialItems = false;
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._removeKeyboardShortcuts?.();
|
||||
}
|
||||
|
||||
private _items: PickerComboBoxItemWithLabel[] = [];
|
||||
protected render() {
|
||||
return html`<ha-textfield
|
||||
.label=${this.label ?? this.hass.localize("ui.common.search")}
|
||||
@input=${this._filterChanged}
|
||||
></ha-textfield>
|
||||
<lit-virtualizer
|
||||
@scroll=${this._onScrollList}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${this._items}
|
||||
.renderItem=${this._renderItem}
|
||||
style="min-height: 36px;"
|
||||
class=${this._listScrolled ? "scrolled" : ""}
|
||||
@focus=${this._focusList}
|
||||
>
|
||||
</lit-virtualizer> `;
|
||||
}
|
||||
|
||||
private _defaultNotFoundItem = memoizeOne(
|
||||
(
|
||||
@@ -159,94 +204,56 @@ export class HaPickerComboBox extends LitElement {
|
||||
return sortedItems;
|
||||
};
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
changedProps.has("value") ||
|
||||
changedProps.has("label") ||
|
||||
changedProps.has("disabled")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !(!changedProps.has("_opened") && this._opened);
|
||||
}
|
||||
private _renderItem = (item: PickerComboBoxItem, index: number) => {
|
||||
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
|
||||
return html`<div
|
||||
id=${`list-item-${index}`}
|
||||
class="combo-box-row ${this._value === item.id ? "current-value" : ""}"
|
||||
.value=${item.id}
|
||||
.index=${index}
|
||||
@click=${this._valueSelected}
|
||||
>
|
||||
${item.id === NO_MATCHING_ITEMS_FOUND_ID
|
||||
? DEFAULT_ROW_RENDERER(item, index)
|
||||
: renderer(item, index)}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (changedProps.has("_opened") && this._opened) {
|
||||
this._items = this._getItems();
|
||||
if (this._initialItems) {
|
||||
this.comboBox.filteredItems = this._items;
|
||||
}
|
||||
this._initialItems = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-combo-box
|
||||
item-id-path="id"
|
||||
item-value-path="id"
|
||||
item-label-path="a11y_label"
|
||||
clear-initial-value
|
||||
.hass=${this.hass}
|
||||
.value=${this._value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.filteredItems=${this._items}
|
||||
.renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._valueChanged}
|
||||
@filter-changed=${this._filterChanged}
|
||||
>
|
||||
</ha-combo-box>
|
||||
`;
|
||||
@eventOptions({ passive: true })
|
||||
private _onScrollList(ev) {
|
||||
const top = ev.target.scrollTop ?? 0;
|
||||
this._listScrolled = top > 0;
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
private _valueSelected = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.detail.value !== this._opened) {
|
||||
this._opened = ev.detail.value;
|
||||
fireEvent(this, "opened-changed", { value: this._opened });
|
||||
}
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
// Clear the input field to prevent showing the old value next time
|
||||
this.comboBox.setTextFieldValue("");
|
||||
const newValue = ev.detail.value?.trim();
|
||||
const value = (ev.currentTarget as any).value as string;
|
||||
const newValue = value?.trim();
|
||||
|
||||
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue !== this._value) {
|
||||
this._setValue(newValue);
|
||||
}
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newValue });
|
||||
};
|
||||
|
||||
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
|
||||
Fuse.createIndex(["search_labels"], states)
|
||||
);
|
||||
|
||||
private _filterChanged(ev: CustomEvent): void {
|
||||
if (!this._opened) return;
|
||||
private _filterChanged = (ev: Event) => {
|
||||
const textfield = ev.target as HaTextField;
|
||||
const searchString = textfield.value.trim();
|
||||
|
||||
const target = ev.target as HaComboBox;
|
||||
const searchString = ev.detail.value.trim() as string;
|
||||
|
||||
const index = this._fuseIndex(this._items);
|
||||
const fuse = new HaFuse(this._items, { shouldSort: false }, index);
|
||||
const index = this._fuseIndex(this._allItems);
|
||||
const fuse = new HaFuse(this._allItems, { shouldSort: false }, index);
|
||||
|
||||
const results = fuse.multiTermsSearch(searchString);
|
||||
let filteredItems = this._items as PickerComboBoxItem[];
|
||||
let filteredItems = this._allItems as PickerComboBoxItem[];
|
||||
if (results) {
|
||||
const items = results.map((result) => result.item);
|
||||
if (items.length === 0) {
|
||||
@@ -260,17 +267,266 @@ export class HaPickerComboBox extends LitElement {
|
||||
}
|
||||
|
||||
if (this.searchFn) {
|
||||
filteredItems = this.searchFn(searchString, filteredItems, this._items);
|
||||
filteredItems = this.searchFn(
|
||||
searchString,
|
||||
filteredItems,
|
||||
this._allItems
|
||||
);
|
||||
}
|
||||
|
||||
target.filteredItems = filteredItems;
|
||||
this._items = filteredItems as PickerComboBoxItemWithLabel[];
|
||||
this._selectedItemIndex = -1;
|
||||
if (this._virtualizerElement) {
|
||||
this._virtualizerElement.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
private _registerKeyboardShortcuts() {
|
||||
this._removeKeyboardShortcuts = tinykeys(this, {
|
||||
ArrowUp: this._selectPreviousItem,
|
||||
ArrowDown: this._selectNextItem,
|
||||
Home: this._selectFirstItem,
|
||||
End: this._selectLastItem,
|
||||
Enter: this._pickSelectedItem,
|
||||
});
|
||||
}
|
||||
|
||||
private _setValue(value: string | undefined) {
|
||||
setTimeout(() => {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}, 0);
|
||||
private _focusList() {
|
||||
if (this._selectedItemIndex === -1) {
|
||||
this._selectNextItem();
|
||||
}
|
||||
}
|
||||
|
||||
private _selectNextItem = (ev?: KeyboardEvent) => {
|
||||
ev?.stopPropagation();
|
||||
ev?.preventDefault();
|
||||
if (!this._virtualizerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._searchFieldElement?.focus();
|
||||
|
||||
const items = this._virtualizerElement.items as PickerComboBoxItem[];
|
||||
|
||||
const maxItems = items.length - 1;
|
||||
|
||||
if (maxItems === -1) {
|
||||
this._resetSelectedItem();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex =
|
||||
maxItems === this._selectedItemIndex
|
||||
? this._selectedItemIndex
|
||||
: this._selectedItemIndex + 1;
|
||||
|
||||
if (!items[nextIndex]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
|
||||
// Skip titles, padding and empty search
|
||||
if (nextIndex === maxItems) {
|
||||
return;
|
||||
}
|
||||
this._selectedItemIndex = nextIndex + 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
}
|
||||
|
||||
this._scrollToSelectedItem();
|
||||
};
|
||||
|
||||
private _selectPreviousItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!this._virtualizerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._selectedItemIndex > 0) {
|
||||
const nextIndex = this._selectedItemIndex - 1;
|
||||
|
||||
const items = this._virtualizerElement.items as PickerComboBoxItem[];
|
||||
|
||||
if (!items[nextIndex]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
|
||||
// Skip titles, padding and empty search
|
||||
if (nextIndex === 0) {
|
||||
return;
|
||||
}
|
||||
this._selectedItemIndex = nextIndex - 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
}
|
||||
|
||||
this._scrollToSelectedItem();
|
||||
}
|
||||
};
|
||||
|
||||
private _selectFirstItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = 0;
|
||||
|
||||
if (
|
||||
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
|
||||
NO_MATCHING_ITEMS_FOUND_ID
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
||||
this._selectedItemIndex = nextIndex + 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
}
|
||||
|
||||
this._scrollToSelectedItem();
|
||||
};
|
||||
|
||||
private _selectLastItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = this._virtualizerElement.items.length - 1;
|
||||
|
||||
if (
|
||||
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
|
||||
NO_MATCHING_ITEMS_FOUND_ID
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
||||
this._selectedItemIndex = nextIndex - 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
}
|
||||
|
||||
this._scrollToSelectedItem();
|
||||
};
|
||||
|
||||
private _scrollToSelectedItem = () => {
|
||||
this._virtualizerElement
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
|
||||
this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._virtualizerElement
|
||||
?.querySelector(`#list-item-${this._selectedItemIndex}`)
|
||||
?.classList.add("selected");
|
||||
});
|
||||
};
|
||||
|
||||
private _pickSelectedItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (this._selectedItemIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if filter button is focused
|
||||
ev.preventDefault();
|
||||
|
||||
const item: any = this._virtualizerElement?.items[this._selectedItemIndex];
|
||||
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
|
||||
fireEvent(this, "value-changed", { value: item.id });
|
||||
}
|
||||
};
|
||||
|
||||
private _resetSelectedItem() {
|
||||
this._virtualizerElement
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
this._selectedItemIndex = -1;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--ha-space-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
ha-textfield {
|
||||
padding: 0 var(--ha-space-3);
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
|
||||
:host([mode="dialog"]) ha-textfield {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-combo-box-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ha-combo-box-item.selected {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
ha-combo-box-item.selected {
|
||||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||||
}
|
||||
}
|
||||
|
||||
lit-virtualizer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
lit-virtualizer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
lit-virtualizer.scrolled {
|
||||
border-top: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
|
||||
.bottom-padding {
|
||||
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.combo-box-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
}
|
||||
.combo-box-row.current-value {
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
}
|
||||
|
||||
.combo-box-row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.combo-box-row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -137,7 +137,7 @@ export class HaSelect extends SelectBase {
|
||||
height: var(--ha-select-height, 56px);
|
||||
}
|
||||
.mdc-select--filled .mdc-floating-label {
|
||||
inset-inline-start: 12px;
|
||||
inset-inline-start: var(--ha-space-4);
|
||||
inset-inline-end: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export class HaSelect extends SelectBase {
|
||||
direction: var(--direction);
|
||||
}
|
||||
.mdc-select .mdc-select__anchor {
|
||||
padding-inline-start: 12px;
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
padding-inline-end: 0px;
|
||||
direction: var(--direction);
|
||||
}
|
||||
@@ -158,7 +158,10 @@ export class HaSelect extends SelectBase {
|
||||
padding-inline-end: var(--select-selected-text-padding-end, 0px);
|
||||
}
|
||||
:host([clearable]) .mdc-select__selected-text-container {
|
||||
padding-inline-end: var(--select-selected-text-padding-end, 12px);
|
||||
padding-inline-end: var(
|
||||
--select-selected-text-padding-end,
|
||||
var(--ha-space-4)
|
||||
);
|
||||
}
|
||||
ha-icon-button {
|
||||
position: absolute;
|
||||
|
||||
@@ -36,6 +36,8 @@ export class HaDeviceSelector extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
@@ -102,6 +104,7 @@ export class HaDeviceSelector extends LitElement {
|
||||
.entityFilter=${this.selector.device?.entity
|
||||
? this._filterEntities
|
||||
: undefined}
|
||||
.placeholder=${this.placeholder}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
allow-custom-entity
|
||||
|
||||
@@ -29,6 +29,8 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: any;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
@@ -69,6 +71,7 @@ export class HaEntitySelector extends LitElement {
|
||||
.excludeEntities=${this.selector.entity?.exclude_entities}
|
||||
.entityFilter=${this._filterEntities}
|
||||
.createDomains=${this._createDomains}
|
||||
.placeholder=${this.placeholder}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
allow-custom-entity
|
||||
@@ -86,6 +89,7 @@ export class HaEntitySelector extends LitElement {
|
||||
.reorder=${this.selector.entity.reorder ?? false}
|
||||
.entityFilter=${this._filterEntities}
|
||||
.createDomains=${this._createDomains}
|
||||
.placeholder=${this.placeholder}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-entities-picker>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ImageSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-textarea";
|
||||
import "../ha-textfield";
|
||||
import "../ha-picture-upload";
|
||||
import "../ha-radio";
|
||||
import "../ha-formfield";
|
||||
import type { HaPictureUpload } from "../ha-picture-upload";
|
||||
import { URL_PREFIX } from "../../data/image_upload";
|
||||
|
||||
@customElement("ha-selector-image")
|
||||
export class HaImageSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public name?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false }) public selector!: ImageSelector;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@state() private showUpload = false;
|
||||
|
||||
protected firstUpdated(changedProps): void {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
if (!this.value || this.value.startsWith(URL_PREFIX)) {
|
||||
this.showUpload = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div>
|
||||
<label>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.image.select_image_with_label",
|
||||
{
|
||||
label:
|
||||
this.label ||
|
||||
this.hass.localize("ui.components.selectors.image.image"),
|
||||
}
|
||||
)}
|
||||
<ha-formfield
|
||||
.label=${this.hass.localize("ui.components.selectors.image.upload")}
|
||||
>
|
||||
<ha-radio
|
||||
name="mode"
|
||||
value="upload"
|
||||
.checked=${this.showUpload}
|
||||
@change=${this._radioGroupPicked}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
<ha-formfield
|
||||
.label=${this.hass.localize("ui.components.selectors.image.url")}
|
||||
>
|
||||
<ha-radio
|
||||
name="mode"
|
||||
value="url"
|
||||
.checked=${!this.showUpload}
|
||||
@change=${this._radioGroupPicked}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
</label>
|
||||
${!this.showUpload
|
||||
? html`
|
||||
<ha-textfield
|
||||
.name=${this.name}
|
||||
.value=${this.value || ""}
|
||||
.placeholder=${this.placeholder || ""}
|
||||
.helper=${this.helper}
|
||||
helperPersistent
|
||||
.disabled=${this.disabled}
|
||||
@input=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
.required=${this.required}
|
||||
></ha-textfield>
|
||||
`
|
||||
: html`
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${this.value?.startsWith(URL_PREFIX) ? this.value : null}
|
||||
.original=${this.selector.image?.original}
|
||||
.cropOptions=${this.selector.image?.crop}
|
||||
select-media
|
||||
@change=${this._pictureChanged}
|
||||
></ha-picture-upload>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _radioGroupPicked(ev): void {
|
||||
this.showUpload = ev.target.value === "upload";
|
||||
}
|
||||
|
||||
private _pictureChanged(ev) {
|
||||
const value = (ev.target as HaPictureUpload).value;
|
||||
|
||||
fireEvent(this, "value-changed", { value: value ?? undefined });
|
||||
}
|
||||
|
||||
private _handleChange(ev) {
|
||||
let value = ev.target.value;
|
||||
if (this.value === value) {
|
||||
return;
|
||||
}
|
||||
if (value === "" && !this.required) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-textarea,
|
||||
ha-textfield {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-image": HaImageSelector;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiPlus } from "@mdi/js";
|
||||
import { mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -7,7 +7,10 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import { getSignedPath } from "../../data/auth";
|
||||
import type { MediaPickedEvent } from "../../data/media-player";
|
||||
import { MediaPlayerEntityFeature } from "../../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
|
||||
@@ -17,8 +20,6 @@ import type { SchemaUnion } from "../ha-form/types";
|
||||
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import "../ha-picture-upload";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
@@ -35,8 +36,7 @@ export class HaMediaSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false })
|
||||
public value?: MediaSelectorValue | MediaSelectorValue[];
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@@ -52,9 +52,6 @@ export class HaMediaSelector extends LitElement {
|
||||
|
||||
@state() private _thumbnailUrl?: string | null;
|
||||
|
||||
// For multiple selection mode, cache signed/rewritten URLs per thumbnail string
|
||||
@state() private _thumbnailUrlMap: Record<string, string | null> = {};
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
@@ -62,15 +59,6 @@ export class HaMediaSelector extends LitElement {
|
||||
}
|
||||
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("selector") && this.value !== undefined) {
|
||||
if (this.selector.media?.multiple && !Array.isArray(this.value)) {
|
||||
this.value = [this.value];
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
} else if (!this.selector.media?.multiple && Array.isArray(this.value)) {
|
||||
this.value = this.value[0];
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
}
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
@@ -78,91 +66,32 @@ export class HaMediaSelector extends LitElement {
|
||||
}
|
||||
|
||||
if (changedProps.has("value")) {
|
||||
if (this.selector.media?.multiple) {
|
||||
const values = Array.isArray(this.value)
|
||||
? this.value
|
||||
: this.value
|
||||
? [this.value]
|
||||
: [];
|
||||
const seenThumbs = new Set<string>();
|
||||
values.forEach((val) => {
|
||||
const thumbnail = val.metadata?.thumbnail;
|
||||
if (!thumbnail) {
|
||||
return;
|
||||
}
|
||||
seenThumbs.add(thumbnail);
|
||||
// Only (re)compute if not cached yet
|
||||
if (this._thumbnailUrlMap[thumbnail] !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrlMap = {
|
||||
...this._thumbnailUrlMap,
|
||||
[thumbnail]: null,
|
||||
};
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
// Avoid losing other keys
|
||||
this._thumbnailUrlMap = {
|
||||
...this._thumbnailUrlMap,
|
||||
[thumbnail]: signedPath.path,
|
||||
};
|
||||
});
|
||||
} else if (thumbnail.startsWith("https://brands.home-assistant.io")) {
|
||||
this._thumbnailUrlMap = {
|
||||
...this._thumbnailUrlMap,
|
||||
[thumbnail]: brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnail),
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
this._thumbnailUrlMap = {
|
||||
...this._thumbnailUrlMap,
|
||||
[thumbnail]: thumbnail,
|
||||
};
|
||||
}
|
||||
const thumbnail = this.value?.metadata?.thumbnail;
|
||||
const oldThumbnail = (changedProps.get("value") as this["value"])
|
||||
?.metadata?.thumbnail;
|
||||
if (thumbnail === oldThumbnail) {
|
||||
return;
|
||||
}
|
||||
if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
// Clean up thumbnails no longer present
|
||||
const newMap: Record<string, string | null> = {};
|
||||
Object.keys(this._thumbnailUrlMap).forEach((key) => {
|
||||
if (seenThumbs.has(key)) {
|
||||
newMap[key] = this._thumbnailUrlMap[key];
|
||||
}
|
||||
} else if (
|
||||
thumbnail &&
|
||||
thumbnail.startsWith("https://brands.home-assistant.io")
|
||||
) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
this._thumbnailUrl = brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnail),
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
this._thumbnailUrlMap = newMap;
|
||||
} else {
|
||||
const currVal = Array.isArray(this.value) ? this.value[0] : this.value;
|
||||
const prevVal = Array.isArray(changedProps.get("value") as any)
|
||||
? (changedProps.get("value") as MediaSelectorValue[])[0]
|
||||
: (changedProps.get("value") as MediaSelectorValue);
|
||||
const thumbnail = currVal?.metadata?.thumbnail;
|
||||
const oldThumbnail = prevVal?.metadata?.thumbnail;
|
||||
if (thumbnail === oldThumbnail) {
|
||||
return;
|
||||
}
|
||||
if (thumbnail && thumbnail.startsWith("/")) {
|
||||
this._thumbnailUrl = undefined;
|
||||
// Thumbnails served by local API require authentication
|
||||
getSignedPath(this.hass, thumbnail).then((signedPath) => {
|
||||
this._thumbnailUrl = signedPath.path;
|
||||
});
|
||||
} else if (
|
||||
thumbnail &&
|
||||
thumbnail.startsWith("https://brands.home-assistant.io")
|
||||
) {
|
||||
// The backend is not aware of the theme used by the users,
|
||||
// so we rewrite the URL to show a proper icon
|
||||
this._thumbnailUrl = brandsUrl({
|
||||
domain: extractDomainFromBrandUrl(thumbnail),
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
} else {
|
||||
this._thumbnailUrl = thumbnail ?? undefined;
|
||||
}
|
||||
this._thumbnailUrl = thumbnail;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,20 +106,16 @@ export class HaMediaSelector extends LitElement {
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
const isMultiple = this.selector.media?.multiple === true;
|
||||
|
||||
if (
|
||||
this.selector.media?.image_upload &&
|
||||
(!this.value || (Array.isArray(this.value) && this.value.length === 0))
|
||||
) {
|
||||
return html`<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -217,6 +142,7 @@ export class HaMediaSelector extends LitElement {
|
||||
`}
|
||||
${!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
@@ -224,47 +150,20 @@ export class HaMediaSelector extends LitElement {
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${Array.isArray(this.value)
|
||||
? this.value[0]
|
||||
: this.value || EMPTY_FORM}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`
|
||||
${isMultiple && Array.isArray(this.value) && this.value.length
|
||||
? html`
|
||||
<ha-chip-set>
|
||||
${this.value.map(
|
||||
(item, idx) => html`
|
||||
<ha-input-chip
|
||||
selected
|
||||
.idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
>${item.metadata?.title ||
|
||||
item.media_content_id}</ha-input-chip
|
||||
>
|
||||
`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${(() => {
|
||||
const currVal = Array.isArray(this.value)
|
||||
? this.value[this.value.length - 1]
|
||||
: this.value;
|
||||
return !currVal?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: currVal.metadata?.title || currVal.media_content_id;
|
||||
})()}
|
||||
aria-label=${!this.value?.media_content_id
|
||||
? this.hass.localize("ui.components.selectors.media.pick_media")
|
||||
: this.value.metadata?.title || this.value.media_content_id}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${this.disabled || (!entityId && !this._hasAccept)
|
||||
@@ -273,22 +172,14 @@ export class HaMediaSelector extends LitElement {
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${!isMultiple &&
|
||||
(Array.isArray(this.value) ? this.value[0] : this.value)
|
||||
?.metadata?.thumbnail
|
||||
${this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!(
|
||||
Array.isArray(this.value)
|
||||
? this.value[0]
|
||||
: this.value
|
||||
)!.metadata!.media_class &&
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
(Array.isArray(this.value)
|
||||
? this.value[0]
|
||||
: this.value)!.metadata!.media_class!
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
@@ -301,27 +192,32 @@ export class HaMediaSelector extends LitElement {
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${mdiPlus}
|
||||
.path=${!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="title">
|
||||
${(() => {
|
||||
const currVal = Array.isArray(this.value)
|
||||
? this.value[this.value.length - 1]
|
||||
: this.value;
|
||||
return !currVal?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: currVal.metadata?.title || currVal.media_content_id;
|
||||
})()}
|
||||
${!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
${this.selector.media?.clearable &&
|
||||
(Array.isArray(this.value) ? this.value.length : this.value)
|
||||
${this.selector.media?.clearable
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@@ -334,8 +230,7 @@ export class HaMediaSelector extends LitElement {
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
`}
|
||||
: nothing}`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -376,61 +271,41 @@ export class HaMediaSelector extends LitElement {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: (Array.isArray(this.value)
|
||||
? this.value[this.value.length - 1]
|
||||
: this.value
|
||||
)?.metadata?.navigateIds,
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: Array.isArray(this.value)
|
||||
? this.value[this.value.length - 1]?.media_content_id
|
||||
: this.value?.media_content_id,
|
||||
defaultType: Array.isArray(this.value)
|
||||
? this.value[this.value.length - 1]?.media_content_type
|
||||
: this.value?.media_content_type,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
const newItem: MediaSelectorValue = {
|
||||
...(Array.isArray(this.value) ? {} : (this.value as any)),
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
if (this.selector.media?.multiple) {
|
||||
const current = Array.isArray(this.value)
|
||||
? this.value
|
||||
: this.value
|
||||
? [this.value]
|
||||
: [];
|
||||
fireEvent(this, "value-changed", {
|
||||
value: [...current, newItem],
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newItem });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const val = Array.isArray(this.value)
|
||||
? this.value[this.value.length - 1]
|
||||
: this.value;
|
||||
const metaId = val?.metadata?.browse_entity_id;
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
val?.entity_id ||
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
);
|
||||
@@ -445,47 +320,27 @@ export class HaMediaSelector extends LitElement {
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
const newItem: MediaSelectorValue = {
|
||||
...(Array.isArray(this.value) ? {} : (this.value as any)),
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
};
|
||||
if (this.selector.media?.multiple) {
|
||||
const current = Array.isArray(this.value)
|
||||
? this.value
|
||||
: this.value
|
||||
? [this.value]
|
||||
: [];
|
||||
fireEvent(this, "value-changed", { value: [...current, newItem] });
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newItem });
|
||||
}
|
||||
|
||||
private _clearValue() {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: this.selector.media?.multiple ? [] : undefined,
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _removeItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!Array.isArray(this.value)) return;
|
||||
const idx = (ev.currentTarget as any).idx as number;
|
||||
if (idx === undefined) return;
|
||||
const newValue = this.value.slice();
|
||||
newValue.splice(idx, 1);
|
||||
fireEvent(this, "value-changed", { value: newValue });
|
||||
private _clearValue() {
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -497,9 +352,6 @@ export class HaMediaSelector extends LitElement {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-chip-set {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js";
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDelete,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -47,9 +52,10 @@ export class HaObjectSelector extends LitElement {
|
||||
const translationKey = this.selector.object?.translation_key;
|
||||
|
||||
if (this.localizeValue && translationKey) {
|
||||
const label = this.localizeValue(
|
||||
`${translationKey}.fields.${schema.name}`
|
||||
);
|
||||
const label =
|
||||
this.localizeValue(`${translationKey}.fields.${schema.name}.name`) ||
|
||||
// Fallback for backward compatibility
|
||||
this.localizeValue(`${translationKey}.fields.${schema.name}`);
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
@@ -57,6 +63,20 @@ export class HaObjectSelector extends LitElement {
|
||||
return this.selector.object?.fields?.[schema.name]?.label || schema.name;
|
||||
};
|
||||
|
||||
private _computeHelper = (schema: HaFormSchema): string => {
|
||||
const translationKey = this.selector.object?.translation_key;
|
||||
|
||||
if (this.localizeValue && translationKey) {
|
||||
const helper = this.localizeValue(
|
||||
`${translationKey}.fields.${schema.name}.description`
|
||||
);
|
||||
if (helper) {
|
||||
return helper;
|
||||
}
|
||||
}
|
||||
return this.selector.object?.fields?.[schema.name]?.description || "";
|
||||
};
|
||||
|
||||
private _renderItem(item: any, index: number) {
|
||||
const labelField =
|
||||
this.selector.object!.label_field ||
|
||||
@@ -92,7 +112,7 @@ export class HaObjectSelector extends LitElement {
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class="handle"
|
||||
.path=${mdiDrag}
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
@@ -209,6 +229,7 @@ export class HaObjectSelector extends LitElement {
|
||||
schema: this._schema(this.selector),
|
||||
data: {},
|
||||
computeLabel: this._computeLabel,
|
||||
computeHelper: this._computeHelper,
|
||||
submitText: this.hass.localize("ui.common.add"),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiDrag } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
@@ -197,7 +197,7 @@ export class HaSelectSelector extends LitElement {
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDrag}
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) {
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.allowName=${this.selector.ui_state_content?.allow_name}
|
||||
.allowName=${this.selector.ui_state_content?.allow_name || false}
|
||||
></ha-entity-state-content-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ const LOAD_ELEMENTS = {
|
||||
file: () => import("./ha-selector-file"),
|
||||
floor: () => import("./ha-selector-floor"),
|
||||
label: () => import("./ha-selector-label"),
|
||||
image: () => import("./ha-selector-image"),
|
||||
background: () => import("./ha-selector-background"),
|
||||
language: () => import("./ha-selector-language"),
|
||||
navigation: () => import("./ha-selector-navigation"),
|
||||
|
||||
@@ -53,7 +53,7 @@ class HaServicePicker extends LitElement {
|
||||
item,
|
||||
{ index }
|
||||
) => html`
|
||||
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
|
||||
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
|
||||
<ha-service-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
@@ -162,7 +162,9 @@ class HaServicePicker extends LitElement {
|
||||
const description =
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.description`
|
||||
) || services[domain][service].description;
|
||||
) ||
|
||||
services[domain][service].description ||
|
||||
"";
|
||||
|
||||
items.push({
|
||||
id: serviceId,
|
||||
|
||||
@@ -29,6 +29,7 @@ import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { toggleAttribute } from "../common/dom/toggle_attribute";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { throttle } from "../common/util/throttle";
|
||||
import { subscribeFrontendUserData } from "../data/frontend";
|
||||
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
|
||||
@@ -536,11 +537,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderUserItem(selectedPanel: string) {
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
href="/profile"
|
||||
type="link"
|
||||
class="user ${selectedPanel === "profile" ? " selected" : ""}"
|
||||
class=${classMap({
|
||||
user: true,
|
||||
selected: selectedPanel === "profile",
|
||||
rtl: isRTL,
|
||||
})}
|
||||
@mouseenter=${this._itemMouseEnter}
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
>
|
||||
@@ -666,7 +673,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
tooltip.style.display = "block";
|
||||
tooltip.style.position = "fixed";
|
||||
tooltip.style.top = `${top}px`;
|
||||
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`;
|
||||
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, var(--ha-space-0)))`;
|
||||
}
|
||||
|
||||
private _hideTooltip() {
|
||||
@@ -705,13 +712,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
background-color: var(--sidebar-background-color);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px));
|
||||
padding-bottom: calc(
|
||||
14px + var(--safe-area-inset-bottom, var(--ha-space-0))
|
||||
);
|
||||
}
|
||||
.menu {
|
||||
height: calc(var(--header-height) + var(--safe-area-inset-top, 0px));
|
||||
height: calc(
|
||||
var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0))
|
||||
);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: 0 4px;
|
||||
padding: 0 var(--ha-space-1);
|
||||
border-bottom: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
@@ -726,13 +737,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
font-size: var(--ha-font-size-xl);
|
||||
align-items: center;
|
||||
padding-left: calc(4px + var(--safe-area-inset-left, 0px));
|
||||
padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px));
|
||||
padding-left: calc(
|
||||
var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
|
||||
);
|
||||
padding-inline-start: calc(
|
||||
var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
|
||||
);
|
||||
padding-inline-end: initial;
|
||||
padding-top: var(--safe-area-inset-top, 0px);
|
||||
padding-top: var(--safe-area-inset-top, var(--ha-space-0));
|
||||
}
|
||||
:host([expanded]) .menu {
|
||||
width: calc(256px + var(--safe-area-inset-left, 0px));
|
||||
width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0)));
|
||||
}
|
||||
:host([narrow][expanded]) .menu {
|
||||
width: 100%;
|
||||
@@ -748,8 +763,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
display: none;
|
||||
}
|
||||
:host([narrow]) .title {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
margin: var(--ha-space-0);
|
||||
padding: var(--ha-space-0) var(--ha-space-4);
|
||||
}
|
||||
:host([expanded]) .title {
|
||||
display: initial;
|
||||
@@ -761,13 +776,16 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
ha-fade-in,
|
||||
ha-md-list {
|
||||
height: calc(
|
||||
100% - var(--header-height) - var(--safe-area-inset-top, 0px) -
|
||||
100% - var(--header-height) - var(
|
||||
--safe-area-inset-top,
|
||||
var(--ha-space-0)
|
||||
) -
|
||||
132px
|
||||
);
|
||||
}
|
||||
|
||||
ha-fade-in {
|
||||
padding: 4px 0;
|
||||
padding: var(--ha-space-1) var(--ha-space-0);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -777,29 +795,29 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
ha-md-list {
|
||||
overflow-x: hidden;
|
||||
background: none;
|
||||
margin-left: var(--safe-area-inset-left, 0px);
|
||||
margin-left: var(--safe-area-inset-left, var(--ha-space-0));
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 4px;
|
||||
margin: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--md-list-item-one-line-container-height: 40px;
|
||||
--md-list-item-one-line-container-height: var(--ha-space-10);
|
||||
--md-list-item-top-space: 0;
|
||||
--md-list-item-bottom-space: 0;
|
||||
width: 48px;
|
||||
width: var(--ha-space-12);
|
||||
position: relative;
|
||||
--md-list-item-label-text-color: var(--sidebar-text-color);
|
||||
--md-list-item-leading-space: 12px;
|
||||
--md-list-item-trailing-space: 12px;
|
||||
--md-list-item-leading-icon-size: 24px;
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
--md-list-item-trailing-space: var(--ha-space-3);
|
||||
--md-list-item-leading-icon-size: var(--ha-space-6);
|
||||
}
|
||||
:host([expanded]) ha-md-list-item {
|
||||
width: 248px;
|
||||
}
|
||||
:host([narrow][expanded]) ha-md-list-item {
|
||||
width: calc(240px - var(--safe-area-inset-left, 0px));
|
||||
width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0)));
|
||||
}
|
||||
|
||||
ha-md-list-item.selected {
|
||||
@@ -823,7 +841,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
|
||||
ha-icon[slot="start"],
|
||||
ha-svg-icon[slot="start"] {
|
||||
width: 24px;
|
||||
width: var(--ha-space-6);
|
||||
flex-shrink: 0;
|
||||
color: var(--sidebar-icon-color);
|
||||
}
|
||||
@@ -856,7 +874,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 8px;
|
||||
min-width: var(--ha-space-2);
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: normal;
|
||||
@@ -867,22 +885,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
|
||||
ha-svg-icon + .badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
top: var(--ha-space-1);
|
||||
left: 26px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
font-size: 0.65em;
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
padding: 0 4px;
|
||||
padding: var(--ha-space-0) var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-md-list-item.user {
|
||||
--md-list-item-leading-icon-size: 40px;
|
||||
--md-list-item-leading-space: 4px;
|
||||
--md-list-item-leading-icon-size: var(--ha-space-10);
|
||||
--md-list-item-leading-space: var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-md-list-item.user.rtl {
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-user-badge {
|
||||
flex-shrink: 0;
|
||||
margin-right: -8px;
|
||||
margin-right: calc(var(--ha-space-2) * -1);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -894,7 +916,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
color: var(--sidebar-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: 16px;
|
||||
padding: var(--ha-space-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -906,7 +928,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
white-space: nowrap;
|
||||
color: var(--sidebar-background-color);
|
||||
background-color: var(--sidebar-text-color);
|
||||
padding: 4px;
|
||||
padding: var(--ha-space-1);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,33 @@ export class HaSlider extends Slider {
|
||||
background-color: var(--ha-slider-thumb-color, var(--primary-color));
|
||||
}
|
||||
|
||||
#thumb:after {
|
||||
content: "";
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
width: calc(var(--thumb-width) * 2 + 8px);
|
||||
height: calc(var(--thumb-height) * 2 + 8px);
|
||||
left: calc(-50% - 4px);
|
||||
top: calc(-50% - 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#slider:focus-visible:not(.disabled) #thumb,
|
||||
#slider:focus-visible:not(.disabled) #thumb-min,
|
||||
#slider:focus-visible:not(.disabled) #thumb-max {
|
||||
outline: var(--wa-focus-ring);
|
||||
}
|
||||
|
||||
#track:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(-50% - 4px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(var(--track-size) * 2 + 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#indicator {
|
||||
background-color: var(
|
||||
--ha-slider-indicator-color,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "@home-assistant/webawesome/dist/components/dialog/dialog";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import "./ha-dialog-header";
|
||||
import "./ha-icon-button";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
query,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-dialog-header";
|
||||
import "./ha-icon-button";
|
||||
|
||||
export type DialogWidth = "small" | "medium" | "large" | "full";
|
||||
|
||||
@@ -90,6 +96,11 @@ export class HaWaDialog extends LitElement {
|
||||
@state()
|
||||
private _open = false;
|
||||
|
||||
@query(".body") public bodyContainer!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
private _bodyScrolled = false;
|
||||
|
||||
protected updated(
|
||||
changedProperties: Map<string | number | symbol, unknown>
|
||||
): void {
|
||||
@@ -107,10 +118,14 @@ export class HaWaDialog extends LitElement {
|
||||
.lightDismiss=${!this.preventScrimClose}
|
||||
without-header
|
||||
@wa-show=${this._handleShow}
|
||||
@wa-after-show=${this._handleAfterShow}
|
||||
@wa-after-hide=${this._handleAfterHide}
|
||||
>
|
||||
<slot name="header">
|
||||
<ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}>
|
||||
<ha-dialog-header
|
||||
.subtitlePosition=${this.headerSubtitlePosition}
|
||||
.showBorder=${this._bodyScrolled}
|
||||
>
|
||||
<slot name="headerNavigationIcon" slot="navigationIcon">
|
||||
<ha-icon-button
|
||||
data-dialog="close"
|
||||
@@ -129,7 +144,7 @@ export class HaWaDialog extends LitElement {
|
||||
<slot name="headerActionItems" slot="actionItems"></slot>
|
||||
</ha-dialog-header>
|
||||
</slot>
|
||||
<div class="body ha-scrollbar">
|
||||
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<slot name="footer" slot="footer"></slot>
|
||||
@@ -146,6 +161,10 @@ export class HaWaDialog extends LitElement {
|
||||
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
|
||||
};
|
||||
|
||||
private _handleAfterShow = () => {
|
||||
fireEvent(this, "after-show");
|
||||
};
|
||||
|
||||
private _handleAfterHide = () => {
|
||||
this._open = false;
|
||||
fireEvent(this, "closed");
|
||||
@@ -156,6 +175,11 @@ export class HaWaDialog extends LitElement {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _handleBodyScroll(ev: Event) {
|
||||
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
@@ -172,7 +196,7 @@ export class HaWaDialog extends LitElement {
|
||||
)
|
||||
)
|
||||
);
|
||||
--width: var(--ha-dialog-width-md, min(580px, var(--full-width)));
|
||||
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
|
||||
--spacing: var(--dialog-content-padding, var(--ha-space-6));
|
||||
--show-duration: var(--ha-dialog-show-duration, 200ms);
|
||||
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
|
||||
@@ -193,11 +217,11 @@ export class HaWaDialog extends LitElement {
|
||||
}
|
||||
|
||||
:host([width="small"]) wa-dialog {
|
||||
--width: var(--ha-dialog-width-sm, min(320px, var(--full-width)));
|
||||
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
|
||||
}
|
||||
|
||||
:host([width="large"]) wa-dialog {
|
||||
--width: var(--ha-dialog-width-lg, min(720px, var(--full-width)));
|
||||
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
|
||||
}
|
||||
|
||||
:host([width="full"]) wa-dialog {
|
||||
@@ -211,6 +235,7 @@ export class HaWaDialog extends LitElement {
|
||||
--ha-dialog-max-height,
|
||||
calc(100% - var(--ha-space-20))
|
||||
);
|
||||
min-height: var(--ha-dialog-min-height);
|
||||
position: var(--dialog-surface-position, relative);
|
||||
margin-top: var(--dialog-surface-margin-top, auto);
|
||||
display: flex;
|
||||
@@ -247,10 +272,7 @@ export class HaWaDialog extends LitElement {
|
||||
.header-title {
|
||||
margin: 0;
|
||||
margin-bottom: 0;
|
||||
color: var(
|
||||
--ha-dialog-header-title-color,
|
||||
var(--ha-color-on-surface-default, var(--primary-text-color))
|
||||
);
|
||||
color: var(--ha-dialog-header-title-color, var(--primary-text-color));
|
||||
font-size: var(
|
||||
--ha-dialog-header-title-font-size,
|
||||
var(--ha-font-size-2xl)
|
||||
@@ -287,6 +309,7 @@ export class HaWaDialog extends LitElement {
|
||||
}
|
||||
:host([flexcontent]) .body {
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -315,6 +338,7 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
opened: undefined;
|
||||
"after-show": undefined;
|
||||
closed: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,10 @@ class HaWebRtcPlayer extends LitElement {
|
||||
if (!this._remoteStream) {
|
||||
return;
|
||||
}
|
||||
// If the track is audio and the player is muted, we do not add it to the stream.
|
||||
if (event.track.kind === "audio" && this.muted) {
|
||||
return;
|
||||
}
|
||||
this._remoteStream.addTrack(event.track);
|
||||
if (!this.hasUpdated) {
|
||||
await this.updateComplete;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { until } from "lit/directives/until";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { isUnavailableState } from "../../data/entity";
|
||||
import type {
|
||||
@@ -693,10 +694,12 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<ha-tooltip .for="grid-${child.title}" distance="-4">
|
||||
<ha-tooltip .for="grid-${slugify(child.title)}" distance="-4">
|
||||
${child.title}
|
||||
</ha-tooltip>
|
||||
<div .id="grid-${child.title}" class="title">${child.title}</div>
|
||||
<div .id="grid-${slugify(child.title)}" class="title">
|
||||
${child.title}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`;
|
||||
|
||||
76
src/components/target-picker/dialog/dialog-target-details.ts
Normal file
76
src/components/target-picker/dialog/dialog-target-details.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../ha-dialog-header";
|
||||
import "../../ha-icon-button";
|
||||
import "../../ha-icon-next";
|
||||
import "../../ha-md-list";
|
||||
import "../../ha-md-list-item";
|
||||
import "../../ha-svg-icon";
|
||||
import "../../ha-wa-dialog";
|
||||
import "../ha-target-picker-item-row";
|
||||
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
|
||||
|
||||
@customElement("ha-dialog-target-details")
|
||||
class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: TargetDetailsDialogParams;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
public showDialog(params: TargetDetailsDialogParams): void {
|
||||
this._params = params;
|
||||
this._opened = true;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._opened = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
this._params = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._opened}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.components.target-picker.target_details"
|
||||
)}
|
||||
header-subtitle=${`${this.hass.localize(
|
||||
`ui.components.target-picker.type.${this._params.type}`
|
||||
)}:
|
||||
${this._params.title}`}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-target-picker-item-row
|
||||
.hass=${this.hass}
|
||||
.type=${this._params.type}
|
||||
.itemId=${this._params.itemId}
|
||||
.deviceFilter=${this._params.deviceFilter}
|
||||
.entityFilter=${this._params.entityFilter}
|
||||
.includeDomains=${this._params.includeDomains}
|
||||
.includeDeviceClasses=${this._params.includeDeviceClasses}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-dialog-target-details": DialogTargetDetails;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
|
||||
import type { TargetType } from "../../../data/target";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
|
||||
|
||||
export type NewBackupType = "automatic" | "manual";
|
||||
|
||||
export interface TargetDetailsDialogParams {
|
||||
title: string;
|
||||
type: TargetType;
|
||||
itemId: string;
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
includeDomains?: string[];
|
||||
includeDeviceClasses?: string[];
|
||||
}
|
||||
|
||||
export const loadTargetDetailsDialog = () => import("./dialog-target-details");
|
||||
|
||||
export const showTargetDetailsDialog = (
|
||||
element: HTMLElement,
|
||||
params: TargetDetailsDialogParams
|
||||
) =>
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "ha-dialog-target-details",
|
||||
dialogImport: loadTargetDetailsDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
113
src/components/target-picker/ha-target-picker-item-group.ts
Normal file
113
src/components/target-picker/ha-target-picker-item-group.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
|
||||
import type { TargetType, TargetTypeFloorless } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import "../ha-expansion-panel";
|
||||
import "../ha-md-list";
|
||||
import "./ha-target-picker-item-row";
|
||||
|
||||
@customElement("ha-target-picker-item-group")
|
||||
export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public type!: TargetTypeFloorless;
|
||||
|
||||
@property({ attribute: false }) public items!: Partial<
|
||||
Record<TargetType, string[]>
|
||||
>;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public collapsed = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/**
|
||||
* Show only targets with entities from specific domains.
|
||||
* @type {Array}
|
||||
* @attr include-domains
|
||||
*/
|
||||
@property({ type: Array, attribute: "include-domains" })
|
||||
public includeDomains?: string[];
|
||||
|
||||
/**
|
||||
* Show only targets with entities of these device classes.
|
||||
* @type {Array}
|
||||
* @attr include-device-classes
|
||||
*/
|
||||
@property({ type: Array, attribute: "include-device-classes" })
|
||||
public includeDeviceClasses?: string[];
|
||||
|
||||
protected render() {
|
||||
let count = 0;
|
||||
Object.values(this.items).forEach((items) => {
|
||||
if (items) {
|
||||
count += items.length;
|
||||
}
|
||||
});
|
||||
|
||||
return html`<ha-expansion-panel
|
||||
.expanded=${!this.collapsed}
|
||||
left-chevron
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="heading">
|
||||
${this.hass.localize(
|
||||
`ui.components.target-picker.selected.${this.type}`,
|
||||
{
|
||||
count,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
${Object.entries(this.items).map(([type, items]) =>
|
||||
items
|
||||
? items.map(
|
||||
(item) =>
|
||||
html`<ha-target-picker-item-row
|
||||
.hass=${this.hass}
|
||||
.type=${type as TargetTypeFloorless}
|
||||
.itemId=${item}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
></ha-target-picker-item-row>`
|
||||
)
|
||||
: nothing
|
||||
)}
|
||||
</ha-expansion-panel>`;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev: CustomEvent) {
|
||||
this.collapsed = !ev.detail.expanded;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
--expansion-panel-content-padding: var(--ha-space-0);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
color: var(--secondary-text-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: unset;
|
||||
}
|
||||
ha-md-list {
|
||||
padding: var(--ha-space-0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-item-group": HaTargetPickerItemGroup;
|
||||
}
|
||||
}
|
||||
694
src/components/target-picker/ha-target-picker-item-row.ts
Normal file
694
src/components/target-picker/ha-target-picker-item-row.ts
Normal file
@@ -0,0 +1,694 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import {
|
||||
computeDeviceName,
|
||||
computeDeviceNameDisplay,
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label_registry";
|
||||
import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
extractFromTarget,
|
||||
type ExtractFromTargetResult,
|
||||
type ExtractFromTargetResultReferenced,
|
||||
type TargetType,
|
||||
} from "../../data/target";
|
||||
import { buttonLinkStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-md-list";
|
||||
import type { HaMdList } from "../ha-md-list";
|
||||
import "../ha-md-list-item";
|
||||
import type { HaMdListItem } from "../ha-md-list-item";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-svg-icon";
|
||||
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
|
||||
|
||||
@customElement("ha-target-picker-item-row")
|
||||
export class HaTargetPickerItemRow extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ reflect: true }) public type!: TargetType;
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ type: Boolean }) public expand = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
|
||||
public subEntry = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "hide-context" })
|
||||
public hideContext = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public parentEntries?: ExtractFromTargetResultReferenced;
|
||||
|
||||
@property({ attribute: false })
|
||||
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/**
|
||||
* Show only targets with entities from specific domains.
|
||||
* @type {Array}
|
||||
* @attr include-domains
|
||||
*/
|
||||
@property({ type: Array, attribute: "include-domains" })
|
||||
public includeDomains?: string[];
|
||||
|
||||
/**
|
||||
* Show only targets with entities of these device classes.
|
||||
* @type {Array}
|
||||
* @attr include-device-classes
|
||||
*/
|
||||
@property({ type: Array, attribute: "include-device-classes" })
|
||||
public includeDeviceClasses?: string[];
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _entries?: ExtractFromTargetResult;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
@query("ha-md-list-item") public item?: HaMdListItem;
|
||||
|
||||
@query("ha-md-list") public list?: HaMdList;
|
||||
|
||||
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.subEntry && changedProps.has("itemId")) {
|
||||
this._updateItemData();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const { name, context, iconPath, fallbackIconPath, stateObject } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const showEntities = this.type !== "entity";
|
||||
|
||||
const entries = this.parentEntries || this._entries;
|
||||
|
||||
// Don't show sub entries that have no entities
|
||||
if (
|
||||
this.subEntry &&
|
||||
this.type !== "entity" &&
|
||||
(!entries || entries.referenced_entities.length === 0)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-md-list-item type="text">
|
||||
<div class="icon" slot="start">
|
||||
${this.subEntry
|
||||
? html`
|
||||
<div class="horizontal-line-wrapper">
|
||||
<div class="horizontal-line"></div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObject}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div slot="headline">${name}</div>
|
||||
${context && !this.hideContext
|
||||
? html`<span slot="supporting-text">${context}</span>`
|
||||
: nothing}
|
||||
${this._domainName && this.subEntry
|
||||
? html`<span slot="supporting-text" class="domain"
|
||||
>${this._domainName}</span
|
||||
>`
|
||||
: nothing}
|
||||
${!this.subEntry && entries && showEntities
|
||||
? html`
|
||||
<div slot="end" class="summary">
|
||||
${showEntities &&
|
||||
!this.expand &&
|
||||
entries?.referenced_entities.length
|
||||
? html`<button class="main link" @click=${this._openDetails}>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</button>`
|
||||
: showEntities
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${!this.expand && !this.subEntry
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.path=${mdiClose}
|
||||
slot="end"
|
||||
@click=${this._removeItem}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-md-list-item>
|
||||
${this.expand && entries && entries.referenced_entities
|
||||
? this._renderEntries()
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEntries() {
|
||||
const entries = this.parentEntries || this._entries;
|
||||
|
||||
let nextType: TargetType =
|
||||
this.type === "floor"
|
||||
? "area"
|
||||
: this.type === "area"
|
||||
? "device"
|
||||
: "entity";
|
||||
|
||||
if (this.type === "label") {
|
||||
if (entries?.referenced_areas.length) {
|
||||
nextType = "area";
|
||||
} else if (entries?.referenced_devices.length) {
|
||||
nextType = "device";
|
||||
}
|
||||
}
|
||||
|
||||
const rows1 =
|
||||
(nextType === "area"
|
||||
? entries?.referenced_areas
|
||||
: nextType === "device" && this.type !== "label"
|
||||
? entries?.referenced_devices
|
||||
: this.type !== "label"
|
||||
? entries?.referenced_entities
|
||||
: []) || [];
|
||||
|
||||
const devicesInAreas = [] as string[];
|
||||
|
||||
const rows1Entries =
|
||||
nextType === "entity"
|
||||
? undefined
|
||||
: rows1.map((rowItem) => {
|
||||
const nextEntries = {
|
||||
referenced_areas: [] as string[],
|
||||
referenced_devices: [] as string[],
|
||||
referenced_entities: [] as string[],
|
||||
};
|
||||
|
||||
if (nextType === "area") {
|
||||
nextEntries.referenced_devices =
|
||||
entries?.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
this.hass.devices?.[device_id]?.area_id === rowItem &&
|
||||
entries?.referenced_entities.some(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === device_id
|
||||
)
|
||||
) || ([] as string[]);
|
||||
|
||||
devicesInAreas.push(...nextEntries.referenced_devices);
|
||||
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
nextEntries.referenced_devices.includes(entity.device_id)
|
||||
);
|
||||
}) || ([] as string[]);
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === rowItem
|
||||
) || ([] as string[]);
|
||||
|
||||
return nextEntries;
|
||||
});
|
||||
|
||||
const entityRows =
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
return (
|
||||
entity.labels.includes(this.itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
);
|
||||
})
|
||||
: nextType === "device" && entries
|
||||
? entries.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities[entity_id].area_id === this.itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRows =
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
!devicesInAreas.includes(device_id) &&
|
||||
this.hass.devices[device_id].labels.includes(this.itemId)
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRowsEntries =
|
||||
deviceRows.length === 0
|
||||
? undefined
|
||||
: deviceRows.map((device_id) => ({
|
||||
referenced_areas: [] as string[],
|
||||
referenced_devices: [] as string[],
|
||||
referenced_entities:
|
||||
entries?.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === device_id
|
||||
) || ([] as string[]),
|
||||
}));
|
||||
|
||||
return html`
|
||||
<div class="entries-tree">
|
||||
<div class="line-wrapper">
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<ha-md-list class="entries">
|
||||
${rows1.map(
|
||||
(itemId, index) => html`
|
||||
<ha-target-picker-item-row
|
||||
sub-entry
|
||||
.hass=${this.hass}
|
||||
.type=${nextType}
|
||||
.itemId=${itemId}
|
||||
.parentEntries=${rows1Entries?.[index]}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
`
|
||||
)}
|
||||
${deviceRows.map(
|
||||
(itemId, index) => html`
|
||||
<ha-target-picker-item-row
|
||||
sub-entry
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${itemId}
|
||||
.parentEntries=${deviceRowsEntries?.[index]}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
`
|
||||
)}
|
||||
${entityRows.map(
|
||||
(itemId) => html`
|
||||
<ha-target-picker-item-row
|
||||
sub-entry
|
||||
.hass=${this.hass}
|
||||
type="entity"
|
||||
.itemId=${itemId}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
></ha-target-picker-item-row>
|
||||
`
|
||||
)}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _updateItemData() {
|
||||
if (this.type === "entity") {
|
||||
this._entries = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entries = await extractFromTarget(this.hass, {
|
||||
[`${this.type}_id`]: [this.itemId],
|
||||
});
|
||||
|
||||
const hiddenAreaIds: string[] = [];
|
||||
if (this.type === "floor" || this.type === "label") {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (hiddenDeviceIds.includes(entity.device_id || "")) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "area" && entity.area_id === this.itemId) ||
|
||||
(this.type === "floor" &&
|
||||
entity.area_id &&
|
||||
entries.referenced_areas.includes(entity.area_id)) ||
|
||||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
|
||||
entries.referenced_devices.includes(entity.device_id || "")
|
||||
) {
|
||||
return entityRegMeetsFilter(
|
||||
entity,
|
||||
this.type === "label",
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
this._entries = entries;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to extract target", e);
|
||||
}
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, item: string) => {
|
||||
if (type === "floor") {
|
||||
const floor = this.hass.floors?.[item];
|
||||
return {
|
||||
name: floor?.name || item,
|
||||
iconPath: floor?.icon,
|
||||
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
|
||||
};
|
||||
}
|
||||
if (type === "area") {
|
||||
const area = this.hass.areas?.[item];
|
||||
return {
|
||||
name: area?.name || item,
|
||||
context: area.floor_id && this.hass.floors?.[area.floor_id]?.name,
|
||||
iconPath: area?.icon,
|
||||
fallbackIconPath: mdiTextureBox,
|
||||
};
|
||||
}
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[item];
|
||||
|
||||
if (device.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
return {
|
||||
name: device ? computeDeviceNameDisplay(device, this.hass) : item,
|
||||
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
|
||||
fallbackIconPath: mdiDevices,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(item));
|
||||
|
||||
const stateObject = this.hass.states[item];
|
||||
const entityName = computeEntityName(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
const { area, device } = getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
|
||||
return {
|
||||
name: entityName || deviceName || item,
|
||||
context,
|
||||
stateObject,
|
||||
};
|
||||
}
|
||||
|
||||
// type label
|
||||
const label = this._labelRegistry.find((lab) => lab.label_id === item);
|
||||
return {
|
||||
name: label?.name || item,
|
||||
iconPath: label?.icon,
|
||||
fallbackIconPath: mdiLabel,
|
||||
};
|
||||
});
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
}
|
||||
|
||||
private _removeItem(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "remove-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
private async _getDeviceDomain(configEntryId: string) {
|
||||
try {
|
||||
const data = await getConfigEntry(this.hass, configEntryId);
|
||||
const domain = data.config_entry.domain;
|
||||
this._iconImg = brandsUrl({
|
||||
domain: domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
|
||||
this._setDomainName(domain);
|
||||
} catch {
|
||||
// failed to load config entry -> ignore
|
||||
}
|
||||
}
|
||||
|
||||
private _openDetails() {
|
||||
showTargetDetailsDialog(this, {
|
||||
title: this._itemData(this.type, this.itemId).name,
|
||||
type: this.type,
|
||||
itemId: this.itemId,
|
||||
deviceFilter: this.deviceFilter,
|
||||
entityFilter: this.entityFilter,
|
||||
includeDomains: this.includeDomains,
|
||||
includeDeviceClasses: this.includeDeviceClasses,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
--md-list-item-top-space: var(--ha-space-0);
|
||||
--md-list-item-bottom-space: var(--ha-space-0);
|
||||
--md-list-item-leading-space: var(--ha-space-2);
|
||||
--md-list-item-trailing-space: var(--ha-space-2);
|
||||
--md-list-item-two-line-container-height: 56px;
|
||||
}
|
||||
|
||||
:host([expand]:not([sub-entry])) ha-md-list-item {
|
||||
border: 2px solid var(--ha-color-border-neutral-loud);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
}
|
||||
|
||||
state-badge {
|
||||
color: var(--ha-color-on-neutral-quiet);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-icon-button-size: 32px;
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
:host([sub-entry]) .summary {
|
||||
margin-right: var(--ha-space-12);
|
||||
}
|
||||
.summary .main {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.summary .secondary {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.entries-tree {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.entries-tree .line-wrapper {
|
||||
padding: var(--ha-space-5);
|
||||
}
|
||||
|
||||
.entries-tree .line-wrapper .line {
|
||||
border-left: 2px dashed var(--divider-color);
|
||||
height: calc(100% - 28px);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
:host([sub-entry]) .entries-tree .line-wrapper .line {
|
||||
height: calc(100% - 12px);
|
||||
top: -18px;
|
||||
}
|
||||
|
||||
.entries {
|
||||
padding: 0;
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
|
||||
.horizontal-line-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.horizontal-line-wrapper .horizontal-line {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
margin-inline-start: -28px;
|
||||
width: 29px;
|
||||
border-top: 2px dashed var(--divider-color);
|
||||
}
|
||||
|
||||
button.link {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button.link:hover,
|
||||
button.link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.domain {
|
||||
width: fit-content;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1);
|
||||
font-family: var(--ha-font-family-code);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-item-row": HaTargetPickerItemRow;
|
||||
}
|
||||
}
|
||||
1105
src/components/target-picker/ha-target-picker-selector.ts
Normal file
1105
src/components/target-picker/ha-target-picker-selector.ts
Normal file
File diff suppressed because it is too large
Load Diff
355
src/components/target-picker/ha-target-picker-value-chip.ts
Normal file
355
src/components/target-picker/ha-target-picker-value-chip.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { consume } from "@lit/context";
|
||||
// @ts-ignore
|
||||
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiTextureBox,
|
||||
mdiUnfoldMoreVertical,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import {
|
||||
computeDeviceName,
|
||||
computeDeviceNameDisplay,
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-md-list";
|
||||
import "../ha-md-list-item";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-tooltip";
|
||||
|
||||
@customElement("ha-target-picker-value-chip")
|
||||
export class HaTargetPickerValueChip extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public type!: TargetType;
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
protected render() {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="mdc-chip ${classMap({
|
||||
[this.type]: true,
|
||||
})}"
|
||||
style=${color
|
||||
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
|
||||
: ""}
|
||||
>
|
||||
${iconPath
|
||||
? html`<ha-icon
|
||||
class="mdc-chip__icon mdc-chip__icon--leading"
|
||||
.icon=${iconPath}
|
||||
></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
class="mdc-chip__icon mdc-chip__icon--leading"
|
||||
alt=${this._domainName || ""}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
class="mdc-chip__icon mdc-chip__icon--leading"
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
class="mdc-chip__icon mdc-chip__icon--leading"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing}
|
||||
<span role="gridcell">
|
||||
<span role="button" tabindex="0" class="mdc-chip__primary-action">
|
||||
<span id="title-${this.itemId}" class="mdc-chip__text"
|
||||
>${name}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
${this.type === "entity"
|
||||
? nothing
|
||||
: html`<span role="gridcell">
|
||||
<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
`ui.components.target-picker.expand_${this.type}_id`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-icon-button
|
||||
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.target-picker.expand"
|
||||
)}
|
||||
.path=${mdiUnfoldMoreVertical}
|
||||
hide-title
|
||||
.id="expand-${slugify(this.itemId)}"
|
||||
.type=${this.type}
|
||||
@click=${this._handleExpand}
|
||||
></ha-icon-button>
|
||||
</span>`}
|
||||
<span role="gridcell">
|
||||
<ha-tooltip .for="remove-${slugify(this.itemId)}">
|
||||
${this.hass.localize(
|
||||
`ui.components.target-picker.remove_${this.type}_id`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-icon-button
|
||||
class="mdc-chip__icon mdc-chip__icon--trailing"
|
||||
.label=${this.hass.localize("ui.components.target-picker.remove")}
|
||||
.path=${mdiClose}
|
||||
hide-title
|
||||
.id="remove-${slugify(this.itemId)}"
|
||||
.type=${this.type}
|
||||
@click=${this._removeItem}
|
||||
></ha-icon-button>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
|
||||
if (type === "floor") {
|
||||
const floor = this.hass.floors?.[itemId];
|
||||
return {
|
||||
name: floor?.name || itemId,
|
||||
iconPath: floor?.icon,
|
||||
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
|
||||
};
|
||||
}
|
||||
if (type === "area") {
|
||||
const area = this.hass.areas?.[itemId];
|
||||
return {
|
||||
name: area?.name || itemId,
|
||||
iconPath: area?.icon,
|
||||
fallbackIconPath: mdiTextureBox,
|
||||
};
|
||||
}
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
return {
|
||||
name: device ? computeDeviceNameDisplay(device, this.hass) : itemId,
|
||||
fallbackIconPath: mdiDevices,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(itemId));
|
||||
|
||||
const stateObject = this.hass.states[itemId];
|
||||
const entityName = computeEntityName(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
const { device } = getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
return {
|
||||
name: entityName || deviceName || itemId,
|
||||
stateObject,
|
||||
};
|
||||
}
|
||||
|
||||
// type label
|
||||
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
|
||||
let color = label?.color ? computeCssColor(label.color) : undefined;
|
||||
if (color?.startsWith("var(")) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = computedStyles.getPropertyValue(
|
||||
color.substring(4, color.length - 1)
|
||||
);
|
||||
}
|
||||
if (color?.startsWith("#")) {
|
||||
color = hex2rgb(color).join(",");
|
||||
}
|
||||
return {
|
||||
name: label?.name || itemId,
|
||||
iconPath: label?.icon,
|
||||
fallbackIconPath: mdiLabel,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
}
|
||||
|
||||
private async _getDeviceDomain(configEntryId: string) {
|
||||
try {
|
||||
const data = await getConfigEntry(this.hass, configEntryId);
|
||||
const domain = data.config_entry.domain;
|
||||
this._iconImg = brandsUrl({
|
||||
domain: domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
});
|
||||
|
||||
this._setDomainName(domain);
|
||||
} catch {
|
||||
// failed to load config entry -> ignore
|
||||
}
|
||||
}
|
||||
|
||||
private _removeItem(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "remove-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "expand-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
${unsafeCSS(chipStyles)}
|
||||
.mdc-chip {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.mdc-chip.add {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
.add-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.mdc-chip:not(.add) {
|
||||
cursor: default;
|
||||
}
|
||||
.mdc-chip ha-icon-button {
|
||||
--mdc-icon-button-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
}
|
||||
.mdc-chip ha-icon-button ha-svg-icon {
|
||||
border-radius: 50%;
|
||||
background: var(--secondary-text-color);
|
||||
}
|
||||
.mdc-chip__icon.mdc-chip__icon--trailing {
|
||||
width: var(--ha-space-4);
|
||||
height: var(--ha-space-4);
|
||||
--mdc-icon-size: 14px;
|
||||
color: var(--secondary-text-color);
|
||||
margin-inline-start: var(--ha-space-1) !important;
|
||||
margin-inline-end: calc(-1 * var(--ha-space-1)) !important;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.mdc-chip__icon--leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--mdc-icon-size: 20px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: 6px;
|
||||
margin-left: -13px !important;
|
||||
margin-inline-start: -13px !important;
|
||||
margin-inline-end: var(--ha-space-1) !important;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.expand-btn {
|
||||
margin-right: var(--ha-space-0);
|
||||
margin-inline-end: var(--ha-space-0);
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.mdc-chip.area:not(.add),
|
||||
.mdc-chip.floor:not(.add) {
|
||||
border: 1px solid #fed6a4;
|
||||
background: var(--card-background-color);
|
||||
}
|
||||
.mdc-chip.area:not(.add) .mdc-chip__icon--leading,
|
||||
.mdc-chip.area.add,
|
||||
.mdc-chip.floor:not(.add) .mdc-chip__icon--leading,
|
||||
.mdc-chip.floor.add {
|
||||
background: #fed6a4;
|
||||
}
|
||||
.mdc-chip.device:not(.add) {
|
||||
border: 1px solid #a8e1fb;
|
||||
background: var(--card-background-color);
|
||||
}
|
||||
.mdc-chip.device:not(.add) .mdc-chip__icon--leading,
|
||||
.mdc-chip.device.add {
|
||||
background: #a8e1fb;
|
||||
}
|
||||
.mdc-chip.entity:not(.add) {
|
||||
border: 1px solid #d2e7b9;
|
||||
background: var(--card-background-color);
|
||||
}
|
||||
.mdc-chip.entity:not(.add) .mdc-chip__icon--leading,
|
||||
.mdc-chip.entity.add {
|
||||
background: #d2e7b9;
|
||||
}
|
||||
.mdc-chip.label:not(.add) {
|
||||
border: 1px solid var(--color, #e0e0e0);
|
||||
background: var(--card-background-color);
|
||||
}
|
||||
.mdc-chip.label:not(.add) .mdc-chip__icon--leading,
|
||||
.mdc-chip.label.add {
|
||||
background: var(--background-color, #e0e0e0);
|
||||
}
|
||||
.mdc-chip:hover {
|
||||
z-index: 5;
|
||||
}
|
||||
:host([disabled]) .mdc-chip {
|
||||
opacity: var(--light-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tooltip-icon-img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-value-chip": HaTargetPickerValueChip;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
mdiCallSplit,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiDotsHorizontal,
|
||||
mdiExcavator,
|
||||
mdiFormatListNumbered,
|
||||
mdiGestureDoubleTap,
|
||||
mdiHandBackRight,
|
||||
@@ -16,10 +14,10 @@ import {
|
||||
mdiRoomService,
|
||||
mdiShuffleDisabled,
|
||||
mdiTimerOutline,
|
||||
mdiTools,
|
||||
mdiTrafficLight,
|
||||
} from "@mdi/js";
|
||||
import type { AutomationElementGroup } from "./automation";
|
||||
import type { AutomationElementGroupCollection } from "./automation";
|
||||
import type { Action } from "./script";
|
||||
|
||||
export const ACTION_ICONS = {
|
||||
condition: mdiAbTesting,
|
||||
@@ -48,37 +46,73 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
|
||||
"variables",
|
||||
]);
|
||||
|
||||
export const ACTION_GROUPS: AutomationElementGroup = {
|
||||
device_id: {},
|
||||
helpers: {
|
||||
icon: mdiTools,
|
||||
members: {},
|
||||
},
|
||||
building_blocks: {
|
||||
icon: mdiExcavator,
|
||||
members: {
|
||||
condition: {},
|
||||
delay: {},
|
||||
wait_template: {},
|
||||
wait_for_trigger: {},
|
||||
repeat_count: {},
|
||||
repeat_while: {},
|
||||
repeat_until: {},
|
||||
repeat_for_each: {},
|
||||
choose: {},
|
||||
if: {},
|
||||
stop: {},
|
||||
sequence: {},
|
||||
parallel: {},
|
||||
variables: {},
|
||||
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device_id: {},
|
||||
serviceGroups: {},
|
||||
},
|
||||
},
|
||||
other: {
|
||||
icon: mdiDotsHorizontal,
|
||||
members: {
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
|
||||
groups: {
|
||||
helpers: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
|
||||
groups: {
|
||||
event: {},
|
||||
service: {},
|
||||
set_conversation_response: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ACTION_BUILDING_BLOCKS_GROUP = {
|
||||
condition: {},
|
||||
delay: {},
|
||||
wait_template: {},
|
||||
wait_for_trigger: {},
|
||||
repeat_count: {},
|
||||
repeat_while: {},
|
||||
repeat_until: {},
|
||||
repeat_for_each: {},
|
||||
choose: {},
|
||||
if: {},
|
||||
stop: {},
|
||||
sequence: {},
|
||||
parallel: {},
|
||||
variables: {},
|
||||
};
|
||||
|
||||
// These will be replaced with the correct action
|
||||
export const VIRTUAL_ACTIONS: Partial<
|
||||
Record<keyof typeof ACTION_BUILDING_BLOCKS_GROUP, Action>
|
||||
> = {
|
||||
repeat_count: {
|
||||
repeat: {
|
||||
count: 2,
|
||||
sequence: [],
|
||||
},
|
||||
},
|
||||
repeat_while: {
|
||||
repeat: {
|
||||
while: [],
|
||||
sequence: [],
|
||||
},
|
||||
},
|
||||
repeat_until: {
|
||||
repeat: {
|
||||
until: [],
|
||||
sequence: [],
|
||||
},
|
||||
},
|
||||
repeat_for_each: {
|
||||
repeat: {
|
||||
for_each: {},
|
||||
sequence: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
260
src/data/area_floor.ts
Normal file
260
src/data/area_floor.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { AreaRegistryEntry } from "./area_registry";
|
||||
import {
|
||||
getDeviceEntityDisplayLookup,
|
||||
type DeviceEntityDisplayLookup,
|
||||
type DeviceRegistryEntry,
|
||||
} from "./device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity_registry";
|
||||
import {
|
||||
floorCompare,
|
||||
getFloorAreaLookup,
|
||||
type FloorRegistryEntry,
|
||||
} from "./floor_registry";
|
||||
|
||||
export interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
type: "floor" | "area";
|
||||
floor?: FloorRegistryEntry;
|
||||
area?: AreaRegistryEntry;
|
||||
}
|
||||
|
||||
export interface AreaFloorValue {
|
||||
id: string;
|
||||
type: "floor" | "area";
|
||||
}
|
||||
|
||||
export const getAreasAndFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[]
|
||||
): FloorComboBoxItem[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
deviceFilter ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
inputDevices = devices;
|
||||
inputEntities = entities.filter((entity) => entity.area_id);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = states[entity.entity_id];
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
inputDevices = inputDevices!.filter((device) => deviceFilter!(device));
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter(stateObj);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter!(stateObj);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let outputAreas = areas;
|
||||
|
||||
let areaIds: string[] | undefined;
|
||||
|
||||
if (inputDevices) {
|
||||
areaIds = inputDevices
|
||||
.filter((device) => device.area_id)
|
||||
.map((device) => device.area_id!);
|
||||
}
|
||||
|
||||
if (inputEntities) {
|
||||
areaIds = (areaIds ?? []).concat(
|
||||
inputEntities
|
||||
.filter((entity) => entity.area_id)
|
||||
.map((entity) => entity.area_id!)
|
||||
);
|
||||
}
|
||||
|
||||
if (areaIds) {
|
||||
outputAreas = outputAreas.filter((area) => areaIds!.includes(area.area_id));
|
||||
}
|
||||
|
||||
if (excludeAreas) {
|
||||
outputAreas = outputAreas.filter(
|
||||
(area) => !excludeAreas!.includes(area.area_id)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeFloors) {
|
||||
outputAreas = outputAreas.filter(
|
||||
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
|
||||
);
|
||||
}
|
||||
|
||||
const floorAreaLookup = getFloorAreaLookup(outputAreas);
|
||||
const unassignedAreas = Object.values(outputAreas).filter(
|
||||
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
|
||||
);
|
||||
|
||||
const compare = floorCompare(haFloors);
|
||||
|
||||
// @ts-ignore
|
||||
const floorAreaEntries: [
|
||||
FloorRegistryEntry | undefined,
|
||||
AreaRegistryEntry[],
|
||||
][] = Object.entries(floorAreaLookup)
|
||||
.map(([floorId, floorAreas]) => {
|
||||
const floor = floors.find((fl) => fl.floor_id === floorId)!;
|
||||
return [floor, floorAreas] as const;
|
||||
})
|
||||
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
|
||||
|
||||
const items: FloorComboBoxItem[] = [];
|
||||
|
||||
floorAreaEntries.forEach(([floor, floorAreas]) => {
|
||||
if (floor) {
|
||||
const floorName = computeFloorName(floor);
|
||||
|
||||
const areaSearchLabels = floorAreas
|
||||
.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return [area.area_id, areaName, ...area.aliases];
|
||||
})
|
||||
.flat();
|
||||
|
||||
items.push({
|
||||
id: formatId({ id: floor.floor_id, type: "floor" }),
|
||||
type: "floor",
|
||||
primary: floorName,
|
||||
floor: floor,
|
||||
icon: floor.icon || undefined,
|
||||
search_labels: [
|
||||
floor.floor_id,
|
||||
floorName,
|
||||
...floor.aliases,
|
||||
...areaSearchLabels,
|
||||
],
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
...floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
items.push(
|
||||
...unassignedAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
@@ -214,6 +214,8 @@ export interface PipelineRun {
|
||||
stage: "ready" | "wake_word" | "stt" | "intent" | "tts" | "done" | "error";
|
||||
run: PipelineRunStartEvent["data"];
|
||||
error?: PipelineErrorEvent["data"];
|
||||
started: Date;
|
||||
finished?: Date;
|
||||
wake_word?: PipelineWakeWordStartEvent["data"] &
|
||||
Partial<PipelineWakeWordEndEvent["data"]> & { done: boolean };
|
||||
stt?: PipelineSTTStartEvent["data"] &
|
||||
@@ -235,6 +237,7 @@ export const processEvent = (
|
||||
stage: "ready",
|
||||
run: event.data,
|
||||
events: [event],
|
||||
started: new Date(event.timestamp),
|
||||
};
|
||||
return run;
|
||||
}
|
||||
@@ -290,9 +293,14 @@ export const processEvent = (
|
||||
tts: { ...run.tts!, ...event.data, done: true },
|
||||
};
|
||||
} else if (event.type === "run-end") {
|
||||
run = { ...run, stage: "done" };
|
||||
run = { ...run, finished: new Date(event.timestamp), stage: "done" };
|
||||
} else if (event.type === "error") {
|
||||
run = { ...run, stage: "error", error: event.data };
|
||||
run = {
|
||||
...run,
|
||||
finished: new Date(event.timestamp),
|
||||
stage: "error",
|
||||
error: event.data,
|
||||
};
|
||||
} else {
|
||||
run = { ...run };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { createSearchParam } from "../common/url/search-params";
|
||||
import type { Context, HomeAssistant } from "../types";
|
||||
import type { BlueprintInput } from "./blueprint";
|
||||
@@ -293,6 +294,11 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
|
||||
not: Condition[];
|
||||
}
|
||||
|
||||
export interface AutomationElementGroupCollection {
|
||||
titleKey?: LocalizeKeys;
|
||||
groups: AutomationElementGroup;
|
||||
}
|
||||
|
||||
export type AutomationElementGroup = Record<
|
||||
string,
|
||||
{ icon?: string; members?: AutomationElementGroup }
|
||||
|
||||
228
src/data/chat_log.ts
Normal file
228
src/data/chat_log.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const enum ChatLogEventType {
|
||||
INITIAL_STATE = "initial_state",
|
||||
CREATED = "created",
|
||||
UPDATED = "updated",
|
||||
DELETED = "deleted",
|
||||
CONTENT_ADDED = "content_added",
|
||||
}
|
||||
|
||||
export interface ChatLogAttachment {
|
||||
media_content_id: string;
|
||||
mime_type: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ChatLogSystemContent {
|
||||
role: "system";
|
||||
content: string;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
export interface ChatLogUserContent {
|
||||
role: "user";
|
||||
content: string;
|
||||
created: Date;
|
||||
attachments?: ChatLogAttachment[];
|
||||
}
|
||||
|
||||
export interface ChatLogAssistantContent {
|
||||
role: "assistant";
|
||||
agent_id: string;
|
||||
created: Date;
|
||||
content?: string;
|
||||
thinking_content?: string;
|
||||
tool_calls?: any[];
|
||||
}
|
||||
|
||||
export interface ChatLogToolResultContent {
|
||||
role: "tool_result";
|
||||
agent_id: string;
|
||||
tool_call_id: string;
|
||||
tool_name: string;
|
||||
tool_result: any;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
export type ChatLogContent =
|
||||
| ChatLogSystemContent
|
||||
| ChatLogUserContent
|
||||
| ChatLogAssistantContent
|
||||
| ChatLogToolResultContent;
|
||||
|
||||
export interface ChatLog {
|
||||
conversation_id: string;
|
||||
continue_conversation: boolean;
|
||||
content: ChatLogContent[];
|
||||
created: Date;
|
||||
}
|
||||
|
||||
// Internal wire format types (not exported)
|
||||
interface ChatLogSystemContentWire {
|
||||
role: "system";
|
||||
content: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface ChatLogUserContentWire {
|
||||
role: "user";
|
||||
content: string;
|
||||
created: string;
|
||||
attachments?: ChatLogAttachment[];
|
||||
}
|
||||
|
||||
interface ChatLogAssistantContentWire {
|
||||
role: "assistant";
|
||||
agent_id: string;
|
||||
created: string;
|
||||
content?: string;
|
||||
thinking_content?: string;
|
||||
tool_calls?: {
|
||||
tool_name: string;
|
||||
tool_args: Record<string, any>;
|
||||
id: string;
|
||||
external: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ChatLogToolResultContentWire {
|
||||
role: "tool_result";
|
||||
agent_id: string;
|
||||
tool_call_id: string;
|
||||
tool_name: string;
|
||||
tool_result: any;
|
||||
created: string;
|
||||
}
|
||||
|
||||
type ChatLogContentWire =
|
||||
| ChatLogSystemContentWire
|
||||
| ChatLogUserContentWire
|
||||
| ChatLogAssistantContentWire
|
||||
| ChatLogToolResultContentWire;
|
||||
|
||||
interface ChatLogWire {
|
||||
conversation_id: string;
|
||||
continue_conversation: boolean;
|
||||
content: ChatLogContentWire[];
|
||||
created: string;
|
||||
}
|
||||
|
||||
const processContent = (content: ChatLogContentWire): ChatLogContent => ({
|
||||
...content,
|
||||
created: new Date(content.created),
|
||||
});
|
||||
|
||||
const processChatLog = (chatLog: ChatLogWire): ChatLog => ({
|
||||
...chatLog,
|
||||
created: new Date(chatLog.created),
|
||||
content: chatLog.content.map(processContent),
|
||||
});
|
||||
|
||||
interface ChatLogInitialStateEvent {
|
||||
event_type: ChatLogEventType.INITIAL_STATE;
|
||||
data: ChatLogWire;
|
||||
}
|
||||
|
||||
interface ChatLogIndexInitialStateEvent {
|
||||
event_type: ChatLogEventType.INITIAL_STATE;
|
||||
data: ChatLogWire[];
|
||||
}
|
||||
|
||||
interface ChatLogCreatedEvent {
|
||||
conversation_id: string;
|
||||
event_type: ChatLogEventType.CREATED;
|
||||
data: ChatLogWire;
|
||||
}
|
||||
|
||||
interface ChatLogUpdatedEvent {
|
||||
conversation_id: string;
|
||||
event_type: ChatLogEventType.UPDATED;
|
||||
data: { chat_log: ChatLogWire };
|
||||
}
|
||||
|
||||
interface ChatLogDeletedEvent {
|
||||
conversation_id: string;
|
||||
event_type: ChatLogEventType.DELETED;
|
||||
data: ChatLogWire;
|
||||
}
|
||||
|
||||
interface ChatLogContentAddedEvent {
|
||||
conversation_id: string;
|
||||
event_type: ChatLogEventType.CONTENT_ADDED;
|
||||
data: { content: ChatLogContentWire };
|
||||
}
|
||||
|
||||
type ChatLogSubscriptionEvent =
|
||||
| ChatLogInitialStateEvent
|
||||
| ChatLogUpdatedEvent
|
||||
| ChatLogDeletedEvent
|
||||
| ChatLogContentAddedEvent;
|
||||
|
||||
type ChatLogIndexSubscriptionEvent =
|
||||
| ChatLogIndexInitialStateEvent
|
||||
| ChatLogCreatedEvent
|
||||
| ChatLogDeletedEvent;
|
||||
|
||||
export const subscribeChatLog = (
|
||||
hass: HomeAssistant,
|
||||
conversationId: string,
|
||||
callback: (chatLog: ChatLog | null) => void
|
||||
): Promise<UnsubscribeFunc> => {
|
||||
let chatLog: ChatLog | null = null;
|
||||
|
||||
return hass.connection.subscribeMessage<ChatLogSubscriptionEvent>(
|
||||
(event) => {
|
||||
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
|
||||
chatLog = processChatLog(event.data);
|
||||
callback(chatLog);
|
||||
} else if (event.event_type === ChatLogEventType.CONTENT_ADDED) {
|
||||
if (chatLog) {
|
||||
chatLog = {
|
||||
...chatLog,
|
||||
content: [...chatLog.content, processContent(event.data.content)],
|
||||
};
|
||||
callback(chatLog);
|
||||
}
|
||||
} else if (event.event_type === ChatLogEventType.UPDATED) {
|
||||
chatLog = processChatLog(event.data.chat_log);
|
||||
callback(chatLog);
|
||||
} else if (event.event_type === ChatLogEventType.DELETED) {
|
||||
chatLog = null;
|
||||
callback(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "conversation/chat_log/subscribe",
|
||||
conversation_id: conversationId,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const subscribeChatLogIndex = (
|
||||
hass: HomeAssistant,
|
||||
callback: (chatLogs: ChatLog[]) => void
|
||||
): Promise<UnsubscribeFunc> => {
|
||||
let chatLogs: ChatLog[] = [];
|
||||
|
||||
return hass.connection.subscribeMessage<ChatLogIndexSubscriptionEvent>(
|
||||
(event) => {
|
||||
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
|
||||
chatLogs = event.data.map(processChatLog);
|
||||
callback(chatLogs);
|
||||
} else if (event.event_type === ChatLogEventType.CREATED) {
|
||||
chatLogs = [...chatLogs, processChatLog(event.data)];
|
||||
callback(chatLogs);
|
||||
} else if (event.event_type === ChatLogEventType.DELETED) {
|
||||
chatLogs = chatLogs.filter(
|
||||
(chatLog) => chatLog.conversation_id !== event.conversation_id
|
||||
);
|
||||
callback(chatLogs);
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "conversation/chat_log/subscribe_index",
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiDotsHorizontal,
|
||||
mdiExcavator,
|
||||
mdiGateOr,
|
||||
mdiIdentifier,
|
||||
mdiMapClock,
|
||||
@@ -15,7 +13,7 @@ import {
|
||||
mdiStateMachine,
|
||||
mdiWeatherSunny,
|
||||
} from "@mdi/js";
|
||||
import type { AutomationElementGroup } from "./automation";
|
||||
import type { AutomationElementGroupCollection } from "./automation";
|
||||
|
||||
export const CONDITION_ICONS = {
|
||||
device: mdiDevices,
|
||||
@@ -31,25 +29,31 @@ export const CONDITION_ICONS = {
|
||||
zone: mdiMapMarkerRadius,
|
||||
};
|
||||
|
||||
export const CONDITION_GROUPS: AutomationElementGroup = {
|
||||
device: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: { sun: {}, time: {}, zone: {} },
|
||||
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: { sun: {}, time: {}, zone: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
building_blocks: {
|
||||
icon: mdiExcavator,
|
||||
members: { and: {}, or: {}, not: {} },
|
||||
},
|
||||
other: {
|
||||
icon: mdiDotsHorizontal,
|
||||
members: {
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
|
||||
groups: {
|
||||
template: {},
|
||||
trigger: {},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
] as const;
|
||||
|
||||
export const CONDITION_BUILDING_BLOCKS_GROUP = {
|
||||
and: {},
|
||||
or: {},
|
||||
not: {},
|
||||
};
|
||||
|
||||
export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"];
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface DataEntryFlowStepAbort {
|
||||
reason: string;
|
||||
description_placeholders?: Record<string, string>;
|
||||
translation_domain?: string;
|
||||
next_flow?: [FlowType, string]; // [flow_type, flow_id]
|
||||
}
|
||||
|
||||
export interface DataEntryFlowStepProgress {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { getDeviceContext } from "../common/entity/context/get_device_context";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
import type { EntitySources } from "./entity_sources";
|
||||
import { domainToName } from "./integration";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
export {
|
||||
@@ -163,3 +171,147 @@ export const getDeviceIntegrationLookup = (
|
||||
}
|
||||
return deviceIntegrations;
|
||||
};
|
||||
|
||||
export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
}
|
||||
|
||||
export const getDevices = (
|
||||
hass: HomeAssistant,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeDevices?: string[],
|
||||
value?: string
|
||||
): DevicePickerItem[] => {
|
||||
const devices = Object.values(hass.devices);
|
||||
const entities = Object.values(hass.entities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
}
|
||||
|
||||
let inputDevices = devices.filter(
|
||||
(device) => device.id === value || !device.disabled_by
|
||||
);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeDevices) {
|
||||
inputDevices = inputDevices.filter(
|
||||
(device) => !excludeDevices!.includes(device.id)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter(stateObj);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
inputDevices = inputDevices.filter(
|
||||
(device) =>
|
||||
// We always want to include the device of the current value
|
||||
device.id === value || deviceFilter!(device)
|
||||
);
|
||||
}
|
||||
|
||||
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
|
||||
const deviceName = computeDeviceNameDisplay(
|
||||
device,
|
||||
hass,
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { area } = getDeviceContext(device, hass);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
const domain = configEntry?.domain;
|
||||
const domainName = domain ? domainToName(hass.localize, domain) : undefined;
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
label: "",
|
||||
primary:
|
||||
deviceName ||
|
||||
hass.localize("ui.components.device-picker.unnamed_device"),
|
||||
secondary: areaName,
|
||||
domain: configEntry?.domain,
|
||||
domain_name: domainName,
|
||||
search_labels: [deviceName, areaName, domain, domainName].filter(
|
||||
Boolean
|
||||
) as string[],
|
||||
sorting_label: deviceName || "zzz",
|
||||
};
|
||||
});
|
||||
|
||||
return outputDevices;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { arrayLiteralIncludes } from "../common/array/literal-includes";
|
||||
|
||||
export const UNAVAILABLE = "unavailable";
|
||||
@@ -10,3 +11,5 @@ export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const;
|
||||
|
||||
export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES);
|
||||
export const isOffState = arrayLiteralIncludes(OFF_STATES);
|
||||
|
||||
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { Connection, HassEntity } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import { domainToName } from "./integration";
|
||||
import type { LightColor } from "./light";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
@@ -324,3 +329,122 @@ export const getAutomaticEntityIds = (
|
||||
type: "config/entity_registry/get_automatic_entity_ids",
|
||||
entity_ids,
|
||||
});
|
||||
|
||||
export interface EntityComboBoxItem extends PickerComboBoxItem {
|
||||
domain_name?: string;
|
||||
stateObj?: HassEntity;
|
||||
}
|
||||
|
||||
export const getEntities = (
|
||||
hass: HomeAssistant,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeDeviceClasses?: string[],
|
||||
includeUnitOfMeasurement?: string[],
|
||||
includeEntities?: string[],
|
||||
excludeEntities?: string[],
|
||||
value?: string
|
||||
): EntityComboBoxItem[] => {
|
||||
let items: EntityComboBoxItem[] = [];
|
||||
|
||||
let entityIds = Object.keys(hass.states);
|
||||
|
||||
if (includeEntities) {
|
||||
entityIds = entityIds.filter((entityId) =>
|
||||
includeEntities.includes(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeEntities) {
|
||||
entityIds = entityIds.filter(
|
||||
(entityId) => !excludeEntities.includes(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDomains) {
|
||||
entityIds = entityIds.filter((eid) =>
|
||||
includeDomains.includes(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
entityIds = entityIds.filter(
|
||||
(eid) => !excludeDomains.includes(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domainName = domainToName(hass.localize, computeDomain(entityId));
|
||||
|
||||
const isRTL = computeRTL(hass);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
|
||||
|
||||
return {
|
||||
id: entityId,
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
domain_name: domainName,
|
||||
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
|
||||
search_labels: [
|
||||
entityName,
|
||||
deviceName,
|
||||
areaName,
|
||||
domainName,
|
||||
friendlyName,
|
||||
entityId,
|
||||
].filter(Boolean) as string[],
|
||||
a11y_label: a11yLabel,
|
||||
stateObj: stateObj,
|
||||
};
|
||||
});
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === value ||
|
||||
(item.stateObj?.attributes.device_class &&
|
||||
includeDeviceClasses.includes(item.stateObj.attributes.device_class))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeUnitOfMeasurement) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === value ||
|
||||
(item.stateObj?.attributes.unit_of_measurement &&
|
||||
includeUnitOfMeasurement.includes(
|
||||
item.stateObj.attributes.unit_of_measurement
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === value || (item.stateObj && entityFilter!(item.stateObj))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -68,13 +68,18 @@ export const getFloorAreaLookup = (
|
||||
};
|
||||
|
||||
export const floorCompare =
|
||||
(entries?: FloorRegistryEntry[], order?: string[]) =>
|
||||
(entries?: HomeAssistant["floors"], order?: string[]) =>
|
||||
(a: string, b: string) => {
|
||||
const indexA = order ? order.indexOf(a) : -1;
|
||||
const indexB = order ? order.indexOf(b) : -1;
|
||||
if (indexA === -1 && indexB === -1) {
|
||||
const nameA = entries?.[a]?.name ?? a;
|
||||
const nameB = entries?.[b]?.name ?? b;
|
||||
const floorA = entries?.[a];
|
||||
const floorB = entries?.[b];
|
||||
if (floorA && floorB && floorA.level !== floorB.level) {
|
||||
return (floorB.level ?? -9999) - (floorA.level ?? -9999);
|
||||
}
|
||||
const nameA = floorA?.name ?? a;
|
||||
const nameB = floorB?.name ?? b;
|
||||
return stringCompare(nameA, nameB);
|
||||
}
|
||||
if (indexA === -1) {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { mdiLabel } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
getDeviceEntityDisplayLookup,
|
||||
type DeviceEntityDisplayLookup,
|
||||
type DeviceRegistryEntry,
|
||||
} from "./device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity_registry";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
export interface LabelRegistryEntry extends RegistryEntry {
|
||||
@@ -88,3 +99,178 @@ export const deleteLabelRegistryEntry = (
|
||||
type: "config/label_registry/delete",
|
||||
label_id: labelId,
|
||||
});
|
||||
|
||||
export const getLabels = (
|
||||
hass: HomeAssistant,
|
||||
labels?: LabelRegistryEntry[],
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeLabels?: string[]
|
||||
): PickerComboBoxItem[] => {
|
||||
if (!labels || labels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
const entities = Object.values(hass.entities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
deviceFilter ||
|
||||
entityFilter
|
||||
) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
inputDevices = devices;
|
||||
inputEntities = entities.filter((entity) => entity.labels.length > 0);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
inputEntities = inputEntities!.filter(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
inputDevices = inputDevices!.filter((device) => deviceFilter!(device));
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices!.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter(stateObj);
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter!(stateObj);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let outputLabels = labels;
|
||||
const usedLabels = new Set<string>();
|
||||
|
||||
let areaIds: string[] | undefined;
|
||||
|
||||
if (inputDevices) {
|
||||
areaIds = inputDevices
|
||||
.filter((device) => device.area_id)
|
||||
.map((device) => device.area_id!);
|
||||
|
||||
inputDevices.forEach((device) => {
|
||||
device.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
if (inputEntities) {
|
||||
areaIds = (areaIds ?? []).concat(
|
||||
inputEntities
|
||||
.filter((entity) => entity.area_id)
|
||||
.map((entity) => entity.area_id!)
|
||||
);
|
||||
inputEntities.forEach((entity) => {
|
||||
entity.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
if (areaIds) {
|
||||
areaIds.forEach((areaId) => {
|
||||
const area = hass.areas[areaId];
|
||||
area.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeLabels) {
|
||||
outputLabels = outputLabels.filter(
|
||||
(label) => !excludeLabels!.includes(label.label_id)
|
||||
);
|
||||
}
|
||||
|
||||
if (inputDevices || inputEntities) {
|
||||
outputLabels = outputLabels.filter((label) =>
|
||||
usedLabels.has(label.label_id)
|
||||
);
|
||||
}
|
||||
|
||||
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
|
||||
id: label.label_id,
|
||||
primary: label.name,
|
||||
icon: label.icon || undefined,
|
||||
icon_path: label.icon ? undefined : mdiLabel,
|
||||
sorting_label: label.name,
|
||||
search_labels: [label.name, label.label_id, label.description].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
),
|
||||
}));
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -316,7 +316,6 @@ export interface MediaSelector {
|
||||
clearable?: boolean;
|
||||
hide_content_type?: boolean;
|
||||
content_id_helper?: string;
|
||||
multiple?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -353,6 +352,7 @@ export interface NumberSelector {
|
||||
interface ObjectSelectorField {
|
||||
selector: Selector;
|
||||
label?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
|
||||
164
src/data/target.ts
Normal file
164
src/data/target.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { AreaRegistryEntry } from "./area_registry";
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity_registry";
|
||||
|
||||
export type TargetType = "entity" | "device" | "area" | "label" | "floor";
|
||||
export type TargetTypeFloorless = Exclude<TargetType, "floor">;
|
||||
|
||||
export interface ExtractFromTargetResult {
|
||||
missing_areas: string[];
|
||||
missing_devices: string[];
|
||||
missing_floors: string[];
|
||||
missing_labels: string[];
|
||||
referenced_areas: string[];
|
||||
referenced_devices: string[];
|
||||
referenced_entities: string[];
|
||||
}
|
||||
|
||||
export interface ExtractFromTargetResultReferenced {
|
||||
referenced_areas: string[];
|
||||
referenced_devices: string[];
|
||||
referenced_entities: string[];
|
||||
}
|
||||
|
||||
export const extractFromTarget = async (
|
||||
hass: HomeAssistant,
|
||||
target: HassServiceTarget
|
||||
) =>
|
||||
hass.callWS<ExtractFromTargetResult>({
|
||||
type: "extract_from_target",
|
||||
target,
|
||||
});
|
||||
|
||||
export const areaMeetsFilter = (
|
||||
area: AreaRegistryEntry,
|
||||
devices: HomeAssistant["devices"],
|
||||
entities: HomeAssistant["entities"],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
states?: HomeAssistant["states"],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc
|
||||
): boolean => {
|
||||
const areaDevices = Object.values(devices).filter(
|
||||
(device) => device.area_id === area.area_id
|
||||
);
|
||||
|
||||
if (
|
||||
areaDevices.some((device) =>
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
entities,
|
||||
deviceFilter,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
states,
|
||||
entityFilter
|
||||
)
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const areaEntities = Object.values(entities).filter(
|
||||
(entity) => entity.area_id === area.area_id
|
||||
);
|
||||
|
||||
if (
|
||||
areaEntities.some((entity) =>
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
states,
|
||||
entityFilter
|
||||
)
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const deviceMeetsFilter = (
|
||||
device: DeviceRegistryEntry,
|
||||
entities: HomeAssistant["entities"],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
states?: HomeAssistant["states"],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc
|
||||
): boolean => {
|
||||
const devEntities = Object.values(entities).filter(
|
||||
(entity) => entity.device_id === device.id
|
||||
);
|
||||
|
||||
if (
|
||||
!devEntities.some((entity) =>
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
states,
|
||||
entityFilter
|
||||
)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deviceFilter) {
|
||||
return deviceFilter(device);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const entityRegMeetsFilter = (
|
||||
entity: EntityRegistryDisplayEntry,
|
||||
includeSecondary = false,
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
states?: HomeAssistant["states"],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc
|
||||
): boolean => {
|
||||
if (entity.hidden || (entity.entity_category && !includeSecondary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
includeDomains &&
|
||||
!includeDomains.includes(computeDomain(entity.entity_id))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (includeDeviceClasses) {
|
||||
const stateObj = states?.[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!stateObj.attributes.device_class ||
|
||||
!includeDeviceClasses!.includes(stateObj.attributes.device_class)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
const stateObj = states?.[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return entityFilter!(stateObj);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiDotsHorizontal,
|
||||
mdiFormatListBulleted,
|
||||
mdiGestureDoubleTap,
|
||||
mdiMapClock,
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
import type {
|
||||
AutomationElementGroup,
|
||||
AutomationElementGroupCollection,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
} from "./automation";
|
||||
@@ -49,16 +48,26 @@ export const TRIGGER_ICONS = {
|
||||
list: mdiFormatListBulleted,
|
||||
};
|
||||
|
||||
export const TRIGGER_GROUPS: AutomationElementGroup = {
|
||||
device: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} },
|
||||
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: {
|
||||
calendar: {},
|
||||
sun: {},
|
||||
time: {},
|
||||
time_pattern: {},
|
||||
zone: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
other: {
|
||||
icon: mdiDotsHorizontal,
|
||||
members: {
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label",
|
||||
groups: {
|
||||
event: {},
|
||||
geo_location: {},
|
||||
homeassistant: {},
|
||||
@@ -70,7 +79,7 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
|
||||
persistent_notification: {},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
] as const;
|
||||
|
||||
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
|
||||
"triggers" in trigger;
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { AreaRegistryEntry } from "./area_registry";
|
||||
|
||||
const fetchAreaRegistry = (conn: Connection) =>
|
||||
conn
|
||||
.sendMessagePromise<AreaRegistryEntry[]>({
|
||||
type: "config/area_registry/list",
|
||||
})
|
||||
.then((areas) =>
|
||||
areas.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
|
||||
);
|
||||
conn.sendMessagePromise<AreaRegistryEntry[]>({
|
||||
type: "config/area_registry/list",
|
||||
});
|
||||
|
||||
const subscribeAreaRegistryUpdates = (
|
||||
conn: Connection,
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { FloorRegistryEntry } from "./floor_registry";
|
||||
|
||||
const fetchFloorRegistry = (conn: Connection) =>
|
||||
conn
|
||||
.sendMessagePromise({
|
||||
type: "config/floor_registry/list",
|
||||
})
|
||||
.then((floors) =>
|
||||
(floors as FloorRegistryEntry[]).sort((ent1, ent2) => {
|
||||
if (ent1.level !== ent2.level) {
|
||||
return (ent1.level ?? 9999) - (ent2.level ?? 9999);
|
||||
}
|
||||
return stringCompare(ent1.name, ent2.name);
|
||||
})
|
||||
);
|
||||
conn.sendMessagePromise<FloorRegistryEntry[]>({
|
||||
type: "config/floor_registry/list",
|
||||
});
|
||||
|
||||
const subscribeFloorRegistryUpdates = (
|
||||
conn: Connection,
|
||||
|
||||
@@ -472,7 +472,10 @@ class DataEntryFlowDialog extends LitElement {
|
||||
this._step = undefined;
|
||||
await this.updateComplete;
|
||||
this._step = _step;
|
||||
if (_step.type === "create_entry" && _step.next_flow) {
|
||||
if (
|
||||
(_step.type === "create_entry" || _step.type === "abort") &&
|
||||
_step.next_flow
|
||||
) {
|
||||
// skip device rename if there is a chained flow
|
||||
this._step = undefined;
|
||||
this._handler = undefined;
|
||||
@@ -486,32 +489,36 @@ class DataEntryFlowDialog extends LitElement {
|
||||
carryOverDevices: this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
Object.values(this.hass.devices),
|
||||
_step.result?.entry_id,
|
||||
_step.type === "create_entry" ? _step.result?.entry_id : undefined,
|
||||
this._params!.carryOverDevices
|
||||
).map((device) => device.id),
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
});
|
||||
} else if (_step.next_flow[0] === "options_flow") {
|
||||
showOptionsFlowDialog(
|
||||
this._params!.dialogParentElement!,
|
||||
_step.result!,
|
||||
{
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
}
|
||||
);
|
||||
if (_step.type === "create_entry") {
|
||||
showOptionsFlowDialog(
|
||||
this._params!.dialogParentElement!,
|
||||
_step.result!,
|
||||
{
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (_step.next_flow[0] === "config_subentries_flow") {
|
||||
showSubConfigFlowDialog(
|
||||
this._params!.dialogParentElement!,
|
||||
_step.result!,
|
||||
_step.next_flow[0],
|
||||
{
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
}
|
||||
);
|
||||
if (_step.type === "create_entry") {
|
||||
showSubConfigFlowDialog(
|
||||
this._params!.dialogParentElement!,
|
||||
_step.result!,
|
||||
_step.next_flow[0],
|
||||
{
|
||||
continueFlowId: _step.next_flow[1],
|
||||
navigateToResult: this._params!.navigateToResult,
|
||||
dialogClosedCallback: this._params!.dialogClosedCallback,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.closeDialog();
|
||||
showAlertDialog(this._params!.dialogParentElement!, {
|
||||
|
||||
@@ -212,6 +212,7 @@ export class DialogEnterCode
|
||||
grid-gap: var(--ha-space-6);
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
direction: ltr;
|
||||
}
|
||||
.clear {
|
||||
grid-row-start: 4;
|
||||
|
||||
@@ -77,84 +77,80 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!stateActive(this.stateObj)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const supportsMute = supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
);
|
||||
const supportsSet = supportsFeature(
|
||||
const supportsSliding = supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
);
|
||||
|
||||
const supportsStep = supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.VOLUME_STEP
|
||||
);
|
||||
|
||||
if (!supportsMute && !supportsSet && !supportsStep) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="volume">
|
||||
${supportsMute
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.path=${this.stateObj.attributes.is_volume_muted
|
||||
? mdiVolumeOff
|
||||
: mdiVolumeHigh}
|
||||
.label=${this.hass.localize(
|
||||
`ui.card.media_player.${
|
||||
this.stateObj.attributes.is_volume_muted
|
||||
? "media_volume_unmute"
|
||||
: "media_volume_mute"
|
||||
}`
|
||||
)}
|
||||
@click=${this._toggleMute}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsStep
|
||||
? html` <ha-icon-button
|
||||
action="volume_down"
|
||||
.path=${mdiVolumeMinus}
|
||||
.label=${this.hass.localize(
|
||||
"ui.card.media_player.media_volume_down"
|
||||
)}
|
||||
@click=${this._handleClick}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
${supportsSet
|
||||
? html`
|
||||
${!supportsMute && !supportsStep
|
||||
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
|
||||
: nothing}
|
||||
<ha-slider
|
||||
labeled
|
||||
id="input"
|
||||
.value=${Number(this.stateObj.attributes.volume_level) * 100}
|
||||
@change=${this._selectedValueChanged}
|
||||
></ha-slider>
|
||||
`
|
||||
: nothing}
|
||||
${supportsStep
|
||||
? html`
|
||||
<ha-icon-button
|
||||
action="volume_up"
|
||||
.path=${mdiVolumePlus}
|
||||
.label=${this.hass.localize(
|
||||
"ui.card.media_player.media_volume_up"
|
||||
)}
|
||||
@click=${this._handleClick}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
return html`${(supportsFeature(
|
||||
this.stateObj!,
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
) ||
|
||||
supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) &&
|
||||
stateActive(this.stateObj!)
|
||||
? html`
|
||||
<div class="volume">
|
||||
${supportsMute
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.path=${this.stateObj.attributes.is_volume_muted
|
||||
? mdiVolumeOff
|
||||
: mdiVolumeHigh}
|
||||
.label=${this.hass.localize(
|
||||
`ui.card.media_player.${
|
||||
this.stateObj.attributes.is_volume_muted
|
||||
? "media_volume_unmute"
|
||||
: "media_volume_mute"
|
||||
}`
|
||||
)}
|
||||
@click=${this._toggleMute}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: ""}
|
||||
${supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.VOLUME_STEP
|
||||
) && !supportsSliding
|
||||
? html`
|
||||
<ha-icon-button
|
||||
action="volume_down"
|
||||
.path=${mdiVolumeMinus}
|
||||
.label=${this.hass.localize(
|
||||
"ui.card.media_player.media_volume_down"
|
||||
)}
|
||||
@click=${this._handleClick}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
action="volume_up"
|
||||
.path=${mdiVolumePlus}
|
||||
.label=${this.hass.localize(
|
||||
"ui.card.media_player.media_volume_up"
|
||||
)}
|
||||
@click=${this._handleClick}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsSliding
|
||||
? html`
|
||||
${!supportsMute
|
||||
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
|
||||
: nothing}
|
||||
<ha-slider
|
||||
labeled
|
||||
id="input"
|
||||
.value=${Number(this.stateObj.attributes.volume_level) *
|
||||
100}
|
||||
@change=${this._selectedValueChanged}
|
||||
></ha-slider>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
protected _renderSourceControl() {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import { join } from "lit/directives/join";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
} from "../../common/entity/context/get_entity_context";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-button-menu";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-header";
|
||||
@@ -361,6 +361,8 @@ export class MoreInfoDialog extends LitElement {
|
||||
);
|
||||
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -394,17 +396,13 @@ export class MoreInfoDialog extends LitElement {
|
||||
${breadcrumb.length > 0
|
||||
? !__DEMO__ && isAdmin
|
||||
? html`
|
||||
<button
|
||||
class="breadcrumb"
|
||||
@click=${this._breadcrumbClick}
|
||||
aria-label=${breadcrumb.join(" > ")}
|
||||
>
|
||||
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
|
||||
<button class="breadcrumb" @click=${this._breadcrumbClick}>
|
||||
${breadcrumb.join(isRTL ? " ◂ " : " ▸ ")}
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<p class="breadcrumb">
|
||||
${join(breadcrumb, html`<ha-icon-next></ha-icon-next>`)}
|
||||
${breadcrumb.join(isRTL ? " ◂ " : " ▸ ")}
|
||||
</p>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-dialog-header";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../components/ha-md-dialog";
|
||||
import "../../components/ha-wa-dialog";
|
||||
import "../../components/ha-spinner";
|
||||
import {
|
||||
subscribeBackupEvents,
|
||||
@@ -37,8 +33,6 @@ class DialogRestartWait extends LitElement {
|
||||
|
||||
private _backupEventsSubscription?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
public async showDialog(params: RestartWaitDialogParams): Promise<void> {
|
||||
this._open = true;
|
||||
this._loadBackupState();
|
||||
@@ -49,9 +43,11 @@ class DialogRestartWait extends LitElement {
|
||||
this._actionOnIdle = params.action;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
if (this._backupEventsSubscription) {
|
||||
this._backupEventsSubscription.then((unsub) => {
|
||||
unsub();
|
||||
@@ -62,10 +58,6 @@ class DialogRestartWait extends LitElement {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._dialog?.close();
|
||||
}
|
||||
|
||||
private _getWaitMessage() {
|
||||
switch (this._backupState) {
|
||||
case "create_backup":
|
||||
@@ -80,28 +72,17 @@ class DialogRestartWait extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._open) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const waitMessage = this._getWaitMessage();
|
||||
|
||||
return html`
|
||||
<ha-md-dialog
|
||||
open
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
.headerTitle=${this._title}
|
||||
width="medium"
|
||||
@closed=${this._dialogClosed}
|
||||
.disableCancelAction=${true}
|
||||
>
|
||||
<ha-dialog-header slot="headline">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
.label=${this.hass.localize("ui.common.cancel")}
|
||||
.path=${mdiClose}
|
||||
@click=${this.closeDialog}
|
||||
></ha-icon-button>
|
||||
<span slot="title" .title=${this._title}> ${this._title} </span>
|
||||
</ha-dialog-header>
|
||||
<div slot="content" class="content">
|
||||
<div class="content">
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this.hass.localize("ui.dialogs.restart.error_backup_state", {
|
||||
@@ -113,7 +94,7 @@ class DialogRestartWait extends LitElement {
|
||||
${waitMessage}
|
||||
`}
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,15 +120,9 @@ class DialogRestartWait extends LitElement {
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-md-dialog {
|
||||
ha-wa-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
@media all and (min-width: 550px) {
|
||||
ha-md-dialog {
|
||||
min-width: 500px;
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -18,7 +18,6 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/chips/ha-assist-chip";
|
||||
import "../components/chips/ha-filter-chip";
|
||||
import "../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
|
||||
@@ -33,7 +33,7 @@ const COMPONENTS = {
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
safety: () => import("../panels/safety/ha-panel-safety"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-aliases-editor";
|
||||
import { createCloseHeading } from "../../../components/ha-dialog";
|
||||
import "../../../components/ha-floor-picker";
|
||||
import "../../../components/ha-icon-picker";
|
||||
import "../../../components/ha-labels-picker";
|
||||
import "../../../components/ha-picture-upload";
|
||||
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-icon-picker";
|
||||
import "../../../components/ha-floor-picker";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-textfield";
|
||||
import "../../../components/ha-labels-picker";
|
||||
import type {
|
||||
AreaRegistryEntry,
|
||||
AreaRegistryEntryMutableParams,
|
||||
} from "../../../data/area_registry";
|
||||
import { deleteAreaRegistryEntry } from "../../../data/area_registry";
|
||||
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
|
||||
import {
|
||||
SENSOR_DEVICE_CLASS_HUMIDITY,
|
||||
SENSOR_DEVICE_CLASS_TEMPERATURE,
|
||||
} from "../../../data/sensor";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { createCloseHeading } from "../../../components/ha-dialog";
|
||||
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
|
||||
|
||||
const cropOptions: CropOptions = {
|
||||
round: false,
|
||||
type: "image/jpeg",
|
||||
quality: 0.75,
|
||||
aspectRatio: 1.78,
|
||||
};
|
||||
|
||||
const SENSOR_DOMAINS = ["sensor"];
|
||||
@@ -139,6 +138,7 @@ class DialogAreaDetail extends LitElement {
|
||||
></ha-floor-picker>
|
||||
|
||||
<ha-labels-picker
|
||||
.label=${this.hass.localize("ui.components.label-picker.labels")}
|
||||
.hass=${this.hass}
|
||||
.value=${this._labels}
|
||||
@value-changed=${this._labelsChanged}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mdiDrag, mdiPlus } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
@@ -15,19 +16,18 @@ import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
getService,
|
||||
isService,
|
||||
VIRTUAL_ACTIONS,
|
||||
} from "../../../../data/action";
|
||||
import type { AutomationClipboard } from "../../../../data/automation";
|
||||
import type { Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
PASTE_VALUE,
|
||||
VIRTUAL_ACTIONS,
|
||||
showAddAutomationElementDialog,
|
||||
} from "../show-add-automation-element-dialog";
|
||||
import { automationRowsStyles } from "../styles";
|
||||
import type HaAutomationActionRow from "./ha-automation-action-row";
|
||||
import { getAutomationActionType } from "./ha-automation-action-row";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
|
||||
@customElement("ha-automation-action")
|
||||
export default class HaAutomationAction extends LitElement {
|
||||
@@ -115,7 +115,9 @@ export default class HaAutomationAction extends LitElement {
|
||||
@click=${stopPropagation}
|
||||
.index=${idx}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
|
||||
<ha-svg-icon
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
@@ -134,17 +136,6 @@ export default class HaAutomationAction extends LitElement {
|
||||
"ui.panel.config.automation.editor.actions.add"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._addActionBuildingBlockDialog}
|
||||
appearance="plain"
|
||||
.size=${this.root ? "medium" : "small"}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.add_building_block"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</ha-sortable>
|
||||
@@ -220,15 +211,6 @@ export default class HaAutomationAction extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _addActionBuildingBlockDialog() {
|
||||
showAddAutomationElementDialog(this, {
|
||||
type: "action",
|
||||
add: this._addAction,
|
||||
clipboardItem: getAutomationActionType(this._clipboard?.action),
|
||||
group: "building_blocks",
|
||||
});
|
||||
}
|
||||
|
||||
private _addAction = (action: string) => {
|
||||
let actions: Action[];
|
||||
if (action === PASTE_VALUE) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user