mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-21 01:53:18 +00:00
Compare commits
353 Commits
automation
...
fix-stale-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ad2a55a1 | ||
|
|
c4080a882d | ||
|
|
9785089ea7 | ||
|
|
d220725e5b | ||
|
|
fdb4de9aa8 | ||
|
|
c3b768c111 | ||
|
|
7d9874adfa | ||
|
|
64ad41a533 | ||
|
|
520739dd0e | ||
|
|
30f70e179a | ||
|
|
e66564ff65 | ||
|
|
70ac14ed52 | ||
|
|
e0d881ff53 | ||
|
|
61c0b7394e | ||
|
|
34b2509a76 | ||
|
|
7d03ef6dfc | ||
|
|
96b59c6171 | ||
|
|
7691d2ca4a | ||
|
|
da1c2bdee4 | ||
|
|
509443fbb2 | ||
|
|
07992286b5 | ||
|
|
cf7274b0ba | ||
|
|
501c72d203 | ||
|
|
a0ad488579 | ||
|
|
ead2d1296f | ||
|
|
5ba5408e78 | ||
|
|
eecca1fa55 | ||
|
|
f2ba0fca73 | ||
|
|
fc448ab3a7 | ||
|
|
9269c1ff0a | ||
|
|
b7dcbd559e | ||
|
|
80e0c098f8 | ||
|
|
364c793ee6 | ||
|
|
99f36e1aad | ||
|
|
25dcaa4eb8 | ||
|
|
d92f7e14b4 | ||
|
|
2c1bf3369d | ||
|
|
81d57cf43c | ||
|
|
09053533ff | ||
|
|
7df61f239f | ||
|
|
f89eace462 | ||
|
|
52956eefc6 | ||
|
|
1fbbeba083 | ||
|
|
4e0d2e290a | ||
|
|
641773d5c4 | ||
|
|
3b53867216 | ||
|
|
7ea936088c | ||
|
|
4281240383 | ||
|
|
6b6203986d | ||
|
|
6997ffa580 | ||
|
|
2d2558db40 | ||
|
|
039fc45532 | ||
|
|
209e6f8def | ||
|
|
f6a19eb6c4 | ||
|
|
ceb9967deb | ||
|
|
b2015465fb | ||
|
|
8e4c99049f | ||
|
|
5a5b8c0bbd | ||
|
|
b60d189a69 | ||
|
|
19ed00c677 | ||
|
|
b92775ea2d | ||
|
|
b5bacf85dd | ||
|
|
8f4fe9ba4e | ||
|
|
9179218336 | ||
|
|
274ec50dbd | ||
|
|
2629881a18 | ||
|
|
d7f143a65a | ||
|
|
9cce20bad1 | ||
|
|
c9ad84b234 | ||
|
|
cb89b8aea8 | ||
|
|
a5f4885d95 | ||
|
|
e2e114cb4e | ||
|
|
4a0284455d | ||
|
|
d220eba9f7 | ||
|
|
2edb0325aa | ||
|
|
2e1582a9c1 | ||
|
|
006cdf088a | ||
|
|
d9b0bf21c0 | ||
|
|
7df059b4cf | ||
|
|
4cfc0dd6c3 | ||
|
|
fb9f182dcc | ||
|
|
880b226d10 | ||
|
|
031e6ea789 | ||
|
|
d025a842c4 | ||
|
|
775f145c9f | ||
|
|
f9caf5365e | ||
|
|
b1419b7761 | ||
|
|
be0a673d4e | ||
|
|
8e31316692 | ||
|
|
f9db26166f | ||
|
|
7ceba8d231 | ||
|
|
2a0b4c8f18 | ||
|
|
6c762e0105 | ||
|
|
4ceb4c3c2c | ||
|
|
cebdb46989 | ||
|
|
5aeae9ffa5 | ||
|
|
2ce62841cf | ||
|
|
63c9b85e6c | ||
|
|
03ace97a7e | ||
|
|
9edcfaf6b3 | ||
|
|
5cb7fdbfed | ||
|
|
5a0e1e89e6 | ||
|
|
5ac6906943 | ||
|
|
cf1fb7751f | ||
|
|
22f8ee0d79 | ||
|
|
9e7d162724 | ||
|
|
14addf02b8 | ||
|
|
17bcf59c6a | ||
|
|
0b1aa4a901 | ||
|
|
aab2304d86 | ||
|
|
c013f79826 | ||
|
|
60236c2fee | ||
|
|
20d53a2659 | ||
|
|
6dbc38386c | ||
|
|
ce5a19caa8 | ||
|
|
2cda06e7a6 | ||
|
|
65485ce8c9 | ||
|
|
b73ae60cea | ||
|
|
cef35c6c23 | ||
|
|
6b9685ec9f | ||
|
|
fc9289dc05 | ||
|
|
2a2bca2a61 | ||
|
|
1eda51ddbc | ||
|
|
22738f6d77 | ||
|
|
2f73351c35 | ||
|
|
44b442dc0e | ||
|
|
916731d0ee | ||
|
|
5113594d6b | ||
|
|
edd162df68 | ||
|
|
6278d9be2f | ||
|
|
ba2fef50d0 | ||
|
|
a9774e74cf | ||
|
|
ae3d6c77ca | ||
|
|
4f3feced1b | ||
|
|
49dd217935 | ||
|
|
522cffffa8 | ||
|
|
3124fbe08e | ||
|
|
c705d4e4a1 | ||
|
|
446661915b | ||
|
|
6048356e01 | ||
|
|
c44341331a | ||
|
|
2d46304960 | ||
|
|
b5ff6a991d | ||
|
|
28254ca0f2 | ||
|
|
8605c235ac | ||
|
|
8325161d39 | ||
|
|
90057854c8 | ||
|
|
04c8c82966 | ||
|
|
16b3add987 | ||
|
|
f0e171076e | ||
|
|
48f0b78b95 | ||
|
|
32728d91d7 | ||
|
|
0915a3e29c | ||
|
|
60c5888f6b | ||
|
|
fb599b8b16 | ||
|
|
b1a390789d | ||
|
|
76c0dd1f1f | ||
|
|
96dacfdeca | ||
|
|
5f28ed35d2 | ||
|
|
edd7b4c3dc | ||
|
|
cbea8bbf44 | ||
|
|
23a41e4384 | ||
|
|
f747580b43 | ||
|
|
98fc69674f | ||
|
|
ab1a58b3f3 | ||
|
|
a7ff89385e | ||
|
|
f3d41be3bf | ||
|
|
b73707751a | ||
|
|
61bff43cdb | ||
|
|
0a0d08fa19 | ||
|
|
ae29ba63ff | ||
|
|
0579cd8eb6 | ||
|
|
8c3eafec6d | ||
|
|
b5c2e12016 | ||
|
|
f7a13392cd | ||
|
|
a2cdd592f1 | ||
|
|
f04341a2a2 | ||
|
|
91bdc80a67 | ||
|
|
b4824cc0a7 | ||
|
|
28f375c0d4 | ||
|
|
da7ccac811 | ||
|
|
a8ad921efd | ||
|
|
3b8f219800 | ||
|
|
e36a2e1c70 | ||
|
|
e06ea1047c | ||
|
|
99cb997d08 | ||
|
|
ac3edd20f8 | ||
|
|
0d88d139f0 | ||
|
|
b8d08ccb05 | ||
|
|
7c20316ba5 | ||
|
|
fa633efc87 | ||
|
|
85d461f0fd | ||
|
|
b55e1c9988 | ||
|
|
1da349a36d | ||
|
|
74f7139a09 | ||
|
|
2911cc77fa | ||
|
|
ab20383a3a | ||
|
|
514cb9da9d | ||
|
|
7c52ac8ca7 | ||
|
|
07b4a44228 | ||
|
|
2b28a6c3f2 | ||
|
|
84f2e304cf | ||
|
|
18cd40ab01 | ||
|
|
8e3b1dc6ac | ||
|
|
5cc223a582 | ||
|
|
9a62a9217c | ||
|
|
70be747e9d | ||
|
|
bb57a91494 | ||
|
|
7e22e6c0e2 | ||
|
|
c93f910e56 | ||
|
|
8bf4ff5d25 | ||
|
|
debc3adf19 | ||
|
|
ae21017de8 | ||
|
|
f15f518cc2 | ||
|
|
0e44417051 | ||
|
|
3581b43336 | ||
|
|
32b9676f97 | ||
|
|
7876642f35 | ||
|
|
0e3bcfad5e | ||
|
|
cd1c273d5a | ||
|
|
d92ac4b4b7 | ||
|
|
bfecb1d4a9 | ||
|
|
69a8db00fa | ||
|
|
bbda7affdc | ||
|
|
10c90d222d | ||
|
|
072f70b49f | ||
|
|
7f2a5ecc27 | ||
|
|
a42f6f864a | ||
|
|
a07772c514 | ||
|
|
a6ab6e218f | ||
|
|
ed96657085 | ||
|
|
50ca39722e | ||
|
|
7026e5b375 | ||
|
|
37e8e1b728 | ||
|
|
48369854af | ||
|
|
7715e01126 | ||
|
|
e4ee108e14 | ||
|
|
407609c118 | ||
|
|
68cbaf6481 | ||
|
|
b9b249a317 | ||
|
|
df6578dfdf | ||
|
|
b4a07f504c | ||
|
|
24b8078a9c | ||
|
|
2bd8a657b8 | ||
|
|
61724a52ba | ||
|
|
67d73261a4 | ||
|
|
419ba64bed | ||
|
|
e87155bba3 | ||
|
|
d8a3939e22 | ||
|
|
5141b6aea9 | ||
|
|
9aaf1bdd07 | ||
|
|
a0e7c8e9bb | ||
|
|
8f482776b9 | ||
|
|
2c80183ded | ||
|
|
9bd5e25ee3 | ||
|
|
994193465f | ||
|
|
530b7ed457 | ||
|
|
24332e8288 | ||
|
|
980aaa2bac | ||
|
|
344856cbc4 | ||
|
|
06877cbaaa | ||
|
|
e1ac7e98b0 | ||
|
|
4b2426ab77 | ||
|
|
f9471d6b4c | ||
|
|
0645484258 | ||
|
|
2da3efb812 | ||
|
|
e3674e550f | ||
|
|
cff038fea4 | ||
|
|
bed39fd8e9 | ||
|
|
b8346d0286 | ||
|
|
5f80b74322 | ||
|
|
73e0f1e18d | ||
|
|
0b16b735e8 | ||
|
|
c88dcf10b9 | ||
|
|
08bc23e2e7 | ||
|
|
97d49f06b1 | ||
|
|
50b727393d | ||
|
|
5c6dd2a697 | ||
|
|
87758cc228 | ||
|
|
60e8b8b505 | ||
|
|
3c012c30ac | ||
|
|
84d234a330 | ||
|
|
a12543fe74 | ||
|
|
cc53f977a2 | ||
|
|
a2a38e1da7 | ||
|
|
88c063ba2a | ||
|
|
eb8b2a9d17 | ||
|
|
10e8c2a148 | ||
|
|
e1a8616ab0 | ||
|
|
ccdd71dd64 | ||
|
|
d3e1d55686 | ||
|
|
4f916abcbf | ||
|
|
4548f9daae | ||
|
|
4020bcec42 | ||
|
|
22c0035e60 | ||
|
|
6b6ad8dd2c | ||
|
|
9c4aacdb1f | ||
|
|
3feb40a8f4 | ||
|
|
7a310812e0 | ||
|
|
ee77619da3 | ||
|
|
cfa8eb5370 | ||
|
|
d9d2d6aa03 | ||
|
|
1f46f477c7 | ||
|
|
52667b3266 | ||
|
|
c790d2356c | ||
|
|
f24c009dd7 | ||
|
|
8d42395938 | ||
|
|
1a6d46a7ff | ||
|
|
b286b07cfd | ||
|
|
1859d35f7b | ||
|
|
5709af57de | ||
|
|
bb16cc8c00 | ||
|
|
17c6dc52a8 | ||
|
|
1b8211db6d | ||
|
|
2b2bb77a2b | ||
|
|
64749350ef | ||
|
|
043d4eed85 | ||
|
|
2f2e64bb1d | ||
|
|
b74b02c09f | ||
|
|
ab4c3a4316 | ||
|
|
15de137591 | ||
|
|
465c10b945 | ||
|
|
457c51cf58 | ||
|
|
640f2b9245 | ||
|
|
852caa32be | ||
|
|
67ccfa0f6e | ||
|
|
c3cc566fe3 | ||
|
|
38d02a3f30 | ||
|
|
ad1d1e2260 | ||
|
|
b2eb8ec968 | ||
|
|
7b8884f0fd | ||
|
|
aff1fedc9d | ||
|
|
8f5059c24a | ||
|
|
1e72ad1411 | ||
|
|
c9f96bbe69 | ||
|
|
616c3d4657 | ||
|
|
b1ceece224 | ||
|
|
d695c4c845 | ||
|
|
fdbeb12622 | ||
|
|
29ede122a1 | ||
|
|
519d3d0e53 | ||
|
|
030a9a492c | ||
|
|
2685a007e7 | ||
|
|
9ca1cfbf4a | ||
|
|
0793af6846 | ||
|
|
bb7f441d8d | ||
|
|
2813ed7938 | ||
|
|
9ebfa4029b | ||
|
|
6190ba18ea | ||
|
|
81feea1109 | ||
|
|
be430931cc | ||
|
|
e07194027a | ||
|
|
17d9cd192f |
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,6 +3,9 @@ contact_links:
|
||||
- name: Request a feature for the UI / Dashboards
|
||||
url: https://github.com/orgs/home-assistant/discussions
|
||||
about: Request a new feature for the Home Assistant frontend.
|
||||
- name: Discuss UI or UX design
|
||||
url: https://github.com/OpenHomeFoundation/ux-design/discussions
|
||||
about: Share design feedback and discuss visual or UX changes with the design team.
|
||||
- name: Report a bug that is NOT related to the UI / Dashboards
|
||||
url: https://github.com/home-assistant/core/issues
|
||||
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -69,7 +69,6 @@
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] There is no commented out code in this PR.
|
||||
- [ ] I have followed the [development checklist][dev-checklist]
|
||||
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
|
||||
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
|
||||
|
||||
@@ -105,6 +104,5 @@ To help with the load of incoming pull requests:
|
||||
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -5,6 +5,8 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- Dependencies
|
||||
|
||||
5
.github/workflows/cast_deployment.yaml
vendored
5
.github/workflows/cast_deployment.yaml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -24,6 +27,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
@@ -59,6 +63,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
|
||||
13
.github/workflows/ci.yaml
vendored
13
.github/workflows/ci.yaml
vendored
@@ -18,6 +18,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and check format
|
||||
@@ -25,6 +28,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -59,6 +64,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -77,6 +84,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -89,13 +98,13 @@ jobs:
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
if-no-files-found: error
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
|
||||
11
.github/workflows/codeql-analysis.yml
vendored
11
.github/workflows/codeql-analysis.yml
vendored
@@ -7,6 +7,10 @@ on:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
@@ -28,6 +32,7 @@ jobs:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
@@ -36,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -57,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
|
||||
5
.github/workflows/demo_deployment.yaml
vendored
5
.github/workflows/demo_deployment.yaml
vendored
@@ -9,6 +9,9 @@ on:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -25,6 +28,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
@@ -60,6 +64,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
|
||||
5
.github/workflows/design_deployment.yaml
vendored
5
.github/workflows/design_deployment.yaml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -17,6 +20,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
|
||||
5
.github/workflows/design_preview.yaml
vendored
5
.github/workflows/design_preview.yaml
vendored
@@ -10,6 +10,9 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -22,6 +25,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
|
||||
2
.github/workflows/labeler.yaml
vendored
2
.github/workflows/labeler.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
name: "Pull Request Labeler"
|
||||
|
||||
on: pull_request_target
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
|
||||
4
.github/workflows/lock.yml
vendored
4
.github/workflows/lock.yml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
6
.github/workflows/nightly.yaml
vendored
6
.github/workflows/nightly.yaml
vendored
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
@@ -57,14 +59,14 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
||||
32
.github/workflows/relative-ci.yaml
vendored
32
.github/workflows/relative-ci.yaml
vendored
@@ -1,25 +1,39 @@
|
||||
name: RelativeCI
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
name: Upload stats
|
||||
upload-frontend-modern:
|
||||
name: Upload stats (frontend/modern)
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
strategy:
|
||||
matrix:
|
||||
bundle: [frontend]
|
||||
build: [modern, legacy]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
with:
|
||||
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
artifactName: ${{ format('{0}-bundle-stats', matrix.bundle) }}
|
||||
webpackStatsFile: ${{ format('{0}-{1}.json', matrix.bundle, matrix.build) }}
|
||||
artifactName: frontend-bundle-stats
|
||||
webpackStatsFile: frontend-modern.json
|
||||
|
||||
upload-frontend-legacy:
|
||||
name: Upload stats (frontend/legacy)
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
artifactName: frontend-bundle-stats
|
||||
webpackStatsFile: frontend-legacy.json
|
||||
|
||||
2
.github/workflows/release-drafter.yaml
vendored
2
.github/workflows/release-drafter.yaml
vendored
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
36
.github/workflows/release.yaml
vendored
36
.github/workflows/release.yaml
vendored
@@ -27,6 +27,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -34,13 +36,12 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
@@ -57,16 +58,15 @@ jobs:
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
- name: Upload release assets
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
|
||||
|
||||
wheels-init:
|
||||
name: Init wheels build
|
||||
@@ -74,15 +74,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate requirements.txt
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
# Sleep to give pypi time to populate the new version across mirrors
|
||||
sleep 240
|
||||
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
|
||||
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
|
||||
echo "home-assistant-frontend==$version" > ./requirements.txt
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.12.0
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -99,11 +101,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
- name: Download Translations
|
||||
@@ -113,8 +116,11 @@ jobs:
|
||||
- name: Build landing-page
|
||||
run: landing-page/script/build_landing_page
|
||||
- name: Tar folder
|
||||
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber
|
||||
|
||||
4
.github/workflows/restrict-task-creation.yml
vendored
4
.github/workflows/restrict-task-creation.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@@ -5,6 +5,11 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
5
.github/workflows/translations.yaml
vendored
5
.github/workflows/translations.yaml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
paths:
|
||||
- src/translations/en.json
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
name: Upload
|
||||
@@ -15,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload Translations
|
||||
run: |
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -57,4 +57,4 @@ test/coverage/
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
|
||||
.opencode
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,4 +8,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||
|
||||
@@ -6,9 +6,9 @@ import rootConfig from "../eslint.config.mjs";
|
||||
export default tseslint.config(...rootConfig, {
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"import/extensions": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
"import-x/extensions": "off",
|
||||
"import-x/no-dynamic-require": "off",
|
||||
"global-require": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-arrow-callback": "off",
|
||||
|
||||
@@ -99,6 +99,44 @@ const lokaliseProjects = {
|
||||
frontend: "3420425759f6d6d241f598.13594006",
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
async function pollProcess(lokaliseApi, projectId, processId) {
|
||||
while (true) {
|
||||
const process = await lokaliseApi
|
||||
.queuedProcesses()
|
||||
.get(processId, { project_id: projectId });
|
||||
|
||||
const project =
|
||||
projectId === lokaliseProjects.backend ? "backend" : "frontend";
|
||||
|
||||
if (process.status === "finished") {
|
||||
console.log(`Lokalise export process for ${project} finished`);
|
||||
return process;
|
||||
}
|
||||
|
||||
if (process.status === "failed" || process.status === "cancelled") {
|
||||
throw new Error(
|
||||
`Lokalise export process for ${project} ${process.status}: ${process.message}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Lokalise export process for ${project} in progress...`,
|
||||
process.status,
|
||||
process.details?.items_to_process
|
||||
? `${Math.round(((process.details.items_processed || 0) / process.details.items_to_process) * 100)}% (${process.details.items_processed}/${process.details.items_to_process})`
|
||||
: ""
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
gulp.task("fetch-lokalise", async function () {
|
||||
let apiKey;
|
||||
try {
|
||||
@@ -118,55 +156,60 @@ gulp.task("fetch-lokalise", async function () {
|
||||
]);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(lokaliseProjects).map(([project, projectId]) =>
|
||||
lokaliseApi
|
||||
.files()
|
||||
.download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
})
|
||||
.then((download) => fetch(download.bundle_url))
|
||||
.then((response) => {
|
||||
if (response.status === 200 || response.status === 0) {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
.async_download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
});
|
||||
|
||||
const finishedProcess = await pollProcess(
|
||||
lokaliseApi,
|
||||
projectId,
|
||||
exportProcess.process_id
|
||||
);
|
||||
|
||||
const bundleUrl = finishedProcess.details.download_url;
|
||||
|
||||
console.log(`Downloading translations from: ${bundleUrl}`);
|
||||
|
||||
const response = await fetch(bundleUrl);
|
||||
|
||||
if (response.status !== 200 && response.status !== 0) {
|
||||
throw new Error(response.statusText);
|
||||
})
|
||||
.then(JSZip.loadAsync)
|
||||
.then(async (contents) => {
|
||||
await mkdirPromise;
|
||||
return Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return Promise.resolve();
|
||||
}
|
||||
return file
|
||||
.async("nodebuffer")
|
||||
.then((content) =>
|
||||
fs.writeFile(
|
||||
path.join(
|
||||
inDir,
|
||||
project,
|
||||
filename.split("/").splice(-1)[0]
|
||||
),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Extracting translations...`);
|
||||
|
||||
const contents = await JSZip.loadAsync(await response.arrayBuffer());
|
||||
|
||||
await mkdirPromise;
|
||||
await Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return;
|
||||
}
|
||||
const content = await file.async("nodebuffer");
|
||||
await fs.writeFile(
|
||||
path.join(inDir, project, filename.split("/").splice(-1)[0]),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
);
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/layouts/hass-loading-screen";
|
||||
import { registerServiceWorker } from "../../../../src/util/register-service-worker";
|
||||
import "./hc-layout";
|
||||
import "../../../../src/components/ha-textfield";
|
||||
import "../../../../src/components/input/ha-input";
|
||||
import "../../../../src/components/ha-button";
|
||||
|
||||
const seeFAQ = (qid) => html`
|
||||
@@ -123,11 +123,11 @@ export class HcConnect extends LitElement {
|
||||
To get started, enter your Home Assistant URL and click authorize.
|
||||
If you want a preview instead, click the show demo button.
|
||||
</p>
|
||||
<ha-textfield
|
||||
<ha-input
|
||||
label="Home Assistant URL"
|
||||
placeholder="https://abcdefghijklmnop.ui.nabu.casa"
|
||||
@keydown=${this._handleInputKeyDown}
|
||||
></ha-textfield>
|
||||
></ha-input>
|
||||
${this.error ? html` <p class="error">${this.error}</p> ` : ""}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
@@ -204,7 +204,7 @@ export class HcConnect extends LitElement {
|
||||
}
|
||||
|
||||
private async _handleConnect() {
|
||||
const inputEl = this.shadowRoot!.querySelector("ha-textfield")!;
|
||||
const inputEl = this.shadowRoot!.querySelector("ha-input")!;
|
||||
const value = inputEl.value || "";
|
||||
this.error = undefined;
|
||||
|
||||
@@ -319,7 +319,7 @@ export class HcConnect extends LitElement {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
ha-textfield {
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11,9 +11,9 @@ export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
];
|
||||
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfigIndex = 0;
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfig: Promise<DemoConfig> =
|
||||
demoConfigs[selectedDemoConfigIndex]();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="chromecast-caf-sender" />
|
||||
import { mdiTelevision } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import globals from "globals";
|
||||
import path from "node:path";
|
||||
@@ -13,6 +12,7 @@ import { configs as litConfigs } from "eslint-plugin-lit";
|
||||
import { configs as wcConfigs } from "eslint-plugin-wc";
|
||||
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = path.dirname(_filename);
|
||||
@@ -22,8 +22,27 @@ const compat = new FlatCompat({
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
// Load airbnb-base via FlatCompat for non-import rules only.
|
||||
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
|
||||
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
|
||||
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
|
||||
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
plugins: Object.fromEntries(
|
||||
Object.entries(plugins).filter(([key]) => key !== "import")
|
||||
),
|
||||
rules: Object.fromEntries(
|
||||
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
|
||||
),
|
||||
settings: Object.fromEntries(
|
||||
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
...compat.extends("airbnb-base"),
|
||||
...airbnbConfigs,
|
||||
eslintConfigPrettier,
|
||||
litConfigs["flat/all"],
|
||||
tseslint.configs.recommended,
|
||||
@@ -31,6 +50,7 @@ export default tseslint.config(
|
||||
tseslint.configs.stylistic,
|
||||
wcConfigs["flat/recommended"],
|
||||
a11yConfigs.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
@@ -58,7 +78,7 @@ export default tseslint.config(
|
||||
},
|
||||
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
"import-x/resolver": {
|
||||
webpack: {
|
||||
config: "./rspack.config.cjs",
|
||||
},
|
||||
@@ -87,12 +107,20 @@ export default tseslint.config(
|
||||
"prefer-destructuring": "off",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"prefer-promise-reject-errors": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/no-default-export": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-cycle": "off",
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"object-curly-newline": "off",
|
||||
"default-case": "off",
|
||||
"wc/no-self-class": "off",
|
||||
"no-shadow": "off",
|
||||
"no-use-before-define": "off",
|
||||
|
||||
"import/extensions": [
|
||||
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
|
||||
"import-x/named": "off",
|
||||
"import-x/prefer-default-export": "off",
|
||||
"import-x/no-default-export": "off",
|
||||
"import-x/no-unresolved": "off",
|
||||
"import-x/no-cycle": "off",
|
||||
"import-x/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
@@ -100,12 +128,24 @@ export default tseslint.config(
|
||||
js: "never",
|
||||
},
|
||||
],
|
||||
"import-x/no-mutable-exports": "error",
|
||||
"import-x/no-amd": "error",
|
||||
"import-x/first": "error",
|
||||
"import-x/order": [
|
||||
"error",
|
||||
{ groups: [["builtin", "external", "internal"]] },
|
||||
],
|
||||
"import-x/newline-after-import": "error",
|
||||
"import-x/no-absolute-path": "error",
|
||||
"import-x/no-dynamic-require": "error",
|
||||
"import-x/no-webpack-loader-syntax": "error",
|
||||
"import-x/no-named-default": "error",
|
||||
"import-x/no-self-import": "error",
|
||||
"import-x/no-useless-path-segments": ["error", { commonjs: true }],
|
||||
"import-x/no-import-module-exports": ["error", { exceptions: [] }],
|
||||
"import-x/no-relative-packages": "error",
|
||||
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"object-curly-newline": "off",
|
||||
"default-case": "off",
|
||||
"wc/no-self-class": "off",
|
||||
"no-shadow": "off",
|
||||
// TypeScript rules
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
@@ -185,7 +225,6 @@ export default tseslint.config(
|
||||
allowObjectTypes: "always",
|
||||
},
|
||||
],
|
||||
"no-use-before-define": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,6 +233,12 @@ export default tseslint.config(
|
||||
globals: globals.audioWorklet,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/entrypoints/service-worker.ts"],
|
||||
languageOptions: {
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
|
||||
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
|
||||
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
|
||||
| size | "small"/"medium" | "medium" | Sets the button size. |
|
||||
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
|
||||
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
|
||||
| disabled | Boolean | false | Disables the button and prevents user interaction. |
|
||||
|
||||
|
||||
82
gallery/src/pages/components/ha-input.markdown
Normal file
82
gallery/src/pages/components/ha-input.markdown
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Input
|
||||
---
|
||||
|
||||
# Input `<ha-input>`
|
||||
|
||||
A text input component supporting Home Assistant theming and validation, based on webawesome input.
|
||||
Supports multiple input types including text, number, password, email, search, and more.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Example usage
|
||||
|
||||
```html
|
||||
<ha-input label="Name" value="Hello"></ha-input>
|
||||
|
||||
<ha-input label="Email" type="email" placeholder="you@example.com"></ha-input>
|
||||
|
||||
<ha-input label="Password" type="password" password-toggle></ha-input>
|
||||
|
||||
<ha-input label="Required" required></ha-input>
|
||||
|
||||
<ha-input label="Disabled" disabled value="Can't touch this"></ha-input>
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome input component.
|
||||
|
||||
**Slots**
|
||||
|
||||
- `start`: Content placed before the input (usually for icons or prefixes).
|
||||
- `end`: Content placed after the input (usually for icons or suffixes).
|
||||
- `label`: Custom label content. Overrides the `label` property.
|
||||
- `hint`: Custom hint content. Overrides the `hint` property.
|
||||
- `clear-icon`: Custom clear icon.
|
||||
- `show-password-icon`: Custom show password icon.
|
||||
- `hide-password-icon`: Custom hide password icon.
|
||||
|
||||
**Properties/Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| appearance | "material"/"outlined" | "material" | Sets the input appearance style. "material" is the default filled style, "outlined" uses a bordered style. |
|
||||
| type | "text"/"number"/"password"/"email"/"search"/"tel"/"url"/"date"/"datetime-local"/"time"/"color" | "text" | Sets the input type. |
|
||||
| value | String | - | The current value of the input. |
|
||||
| label | String | "" | The input's label text. |
|
||||
| hint | String | "" | The input's hint/helper text. |
|
||||
| placeholder | String | "" | Placeholder text shown when the input is empty. |
|
||||
| with-clear | Boolean | false | Adds a clear button when the input is not empty. |
|
||||
| readonly | Boolean | false | Makes the input readonly. |
|
||||
| disabled | Boolean | false | Disables the input and prevents user interaction. |
|
||||
| required | Boolean | false | Makes the input a required field. |
|
||||
| password-toggle | Boolean | false | Adds a button to toggle the password visibility. |
|
||||
| without-spin-buttons | Boolean | false | Hides the browser's built-in spin buttons for number inputs. |
|
||||
| auto-validate | Boolean | false | Validates the input on blur instead of on form submit. |
|
||||
| invalid | Boolean | false | Marks the input as invalid. |
|
||||
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
|
||||
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
|
||||
| pattern | String | - | A regular expression pattern to validate input against. |
|
||||
| minlength | Number | - | The minimum length of input that will be considered valid. |
|
||||
| maxlength | Number | - | The maximum length of input that will be considered valid. |
|
||||
| min | Number/String | - | The input's minimum value. Only applies to date and number input types. |
|
||||
| max | Number/String | - | The input's maximum value. Only applies to date and number input types. |
|
||||
| step | Number/"any" | - | Specifies the granularity that the value must adhere to. |
|
||||
|
||||
**CSS Custom Properties**
|
||||
|
||||
- `--ha-input-padding-top` - Padding above the input.
|
||||
- `--ha-input-padding-bottom` - Padding below the input. Defaults to `var(--ha-space-2)`.
|
||||
- `--ha-input-text-align` - Text alignment of the input. Defaults to `start`.
|
||||
- `--ha-input-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
|
||||
---
|
||||
|
||||
## Derivatives
|
||||
|
||||
The following components extend or wrap `ha-input` for specific use cases:
|
||||
|
||||
- **`<ha-input-search>`** — A pre-configured search input with a magnify icon, clear button, and localized "Search" placeholder. Extends `ha-input`.
|
||||
- **`<ha-input-copy>`** — A read-only input with a copy-to-clipboard button. Supports optional value masking with a reveal toggle.
|
||||
- **`<ha-input-multi>`** — A dynamic list of text inputs for managing arrays of strings. Supports adding, removing, and drag-and-drop reordering.
|
||||
240
gallery/src/pages/components/ha-input.ts
Normal file
240
gallery/src/pages/components/ha-input.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/input/ha-input";
|
||||
import "../../../../src/components/input/ha-input-copy";
|
||||
import "../../../../src/components/input/ha-input-multi";
|
||||
import "../../../../src/components/input/ha-input-search";
|
||||
import { internationalizationContext } from "../../../../src/data/context";
|
||||
|
||||
const LOCALIZE_KEYS: Record<string, string> = {
|
||||
"ui.common.copy": "Copy",
|
||||
"ui.common.show": "Show",
|
||||
"ui.common.hide": "Hide",
|
||||
"ui.common.add": "Add",
|
||||
"ui.common.remove": "Remove",
|
||||
"ui.common.search": "Search",
|
||||
"ui.common.copied_clipboard": "Copied to clipboard",
|
||||
};
|
||||
|
||||
@customElement("demo-components-ha-input")
|
||||
export class DemoHaInput extends LitElement {
|
||||
constructor() {
|
||||
super();
|
||||
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
|
||||
// eslint-disable-next-line no-new
|
||||
new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
initialValue: {
|
||||
localize: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {} as any,
|
||||
translationMetadata: {} as any,
|
||||
loadBackendTranslation: (async () => (key: string) => key) as any,
|
||||
loadFragmentTranslation: (async () => (key: string) => key) as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-input in ${mode}">
|
||||
<div class="card-content">
|
||||
<h3>Basic</h3>
|
||||
<div class="row">
|
||||
<ha-input label="Default"></ha-input>
|
||||
<ha-input label="With value" value="Hello"></ha-input>
|
||||
<ha-input
|
||||
label="With placeholder"
|
||||
placeholder="Type here..."
|
||||
></ha-input>
|
||||
</div>
|
||||
|
||||
<h3>Input types</h3>
|
||||
<div class="row">
|
||||
<ha-input label="Text" type="text" value="Text"></ha-input>
|
||||
<ha-input label="Number" type="number" value="42"></ha-input>
|
||||
<ha-input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
></ha-input>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ha-input
|
||||
label="Password"
|
||||
type="password"
|
||||
value="secret"
|
||||
password-toggle
|
||||
></ha-input>
|
||||
<ha-input label="URL" type="url" placeholder="https://...">
|
||||
</ha-input>
|
||||
<ha-input label="Date" type="date"></ha-input>
|
||||
</div>
|
||||
|
||||
<h3>States</h3>
|
||||
<div class="row">
|
||||
<ha-input
|
||||
label="Disabled"
|
||||
disabled
|
||||
value="Disabled"
|
||||
></ha-input>
|
||||
<ha-input
|
||||
label="Readonly"
|
||||
readonly
|
||||
value="Readonly"
|
||||
></ha-input>
|
||||
<ha-input label="Required" required></ha-input>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ha-input
|
||||
label="Invalid"
|
||||
invalid
|
||||
validation-message="This field is required"
|
||||
value=""
|
||||
></ha-input>
|
||||
<ha-input label="With hint" hint="This is a hint"></ha-input>
|
||||
<ha-input
|
||||
label="With clear"
|
||||
with-clear
|
||||
value="Clear me"
|
||||
></ha-input>
|
||||
</div>
|
||||
|
||||
<h3>With slots</h3>
|
||||
<div class="row">
|
||||
<ha-input label="With prefix">
|
||||
<span slot="start">$</span>
|
||||
</ha-input>
|
||||
<ha-input label="With suffix">
|
||||
<span slot="end">kg</span>
|
||||
</ha-input>
|
||||
<ha-input label="With icon">
|
||||
<ha-svg-icon .path=${mdiMagnify} slot="start"></ha-svg-icon>
|
||||
</ha-input>
|
||||
</div>
|
||||
|
||||
<h3>Appearance: outlined</h3>
|
||||
<div class="row">
|
||||
<ha-input
|
||||
appearance="outlined"
|
||||
label="Outlined"
|
||||
value="Hello"
|
||||
></ha-input>
|
||||
<ha-input
|
||||
appearance="outlined"
|
||||
label="Outlined disabled"
|
||||
disabled
|
||||
value="Disabled"
|
||||
></ha-input>
|
||||
<ha-input
|
||||
appearance="outlined"
|
||||
label="Outlined invalid"
|
||||
invalid
|
||||
validation-message="Required"
|
||||
></ha-input>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ha-input
|
||||
appearance="outlined"
|
||||
placeholder="Placeholder only"
|
||||
></ha-input>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Derivatives in ${mode}">
|
||||
<div class="card-content">
|
||||
<h3>ha-input-search</h3>
|
||||
<ha-input-search label="Search label"></ha-input-search>
|
||||
<ha-input-search appearance="outlined"></ha-input-search>
|
||||
|
||||
<h3>ha-input-copy</h3>
|
||||
<ha-input-copy
|
||||
value="my-api-token-123"
|
||||
masked-value="••••••••••••••••••"
|
||||
masked-toggle
|
||||
></ha-input-copy>
|
||||
|
||||
<h3>ha-input-multi</h3>
|
||||
<ha-input-multi
|
||||
label="URL"
|
||||
add-label="Add URL"
|
||||
.value=${["https://example.com"]}
|
||||
></ha-input-multi>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
h3 {
|
||||
margin: var(--ha-space-4) 0 var(--ha-space-1) 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
.row > * {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-input": DemoHaInput;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
@@ -692,7 +692,11 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
([key, value]) => html`
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${value?.name || key}</span>
|
||||
<span slot="description">${value?.description}</span>
|
||||
${value?.description
|
||||
? html`<span slot="description"
|
||||
>${value?.description}</span
|
||||
>`
|
||||
: nothing}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
|
||||
@@ -3,37 +3,73 @@ title: Switch / Toggle
|
||||
---
|
||||
|
||||
<style>
|
||||
ha-switch {
|
||||
display: block;
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
# Switch `<ha-switch>`
|
||||
|
||||
A toggle switch can represent two states: on and off.
|
||||
A toggle switch representing two states: on and off.
|
||||
|
||||
## Examples
|
||||
## Implementation
|
||||
|
||||
Switch in on state
|
||||
### Example usage
|
||||
|
||||
<div class="wrapper">
|
||||
<ha-switch checked></ha-switch>
|
||||
<ha-switch></ha-switch>
|
||||
<ha-switch disabled></ha-switch>
|
||||
<ha-switch disabled checked></ha-switch>
|
||||
</div>
|
||||
|
||||
```html
|
||||
<ha-switch checked></ha-switch>
|
||||
|
||||
Switch in off state
|
||||
<ha-switch></ha-switch>
|
||||
|
||||
Disabled switch
|
||||
<ha-switch disabled></ha-switch>
|
||||
|
||||
## CSS variables
|
||||
<ha-switch disabled checked></ha-switch>
|
||||
```
|
||||
|
||||
For the switch / toggle there are always two variables, one for the on / checked state and one for the off / unchecked state.
|
||||
### API
|
||||
|
||||
The track element (background rounded rectangle that the round circular handle travels on) is set to being half transparent, so the final color will also be impacted by the color behind the track.
|
||||
This component is based on the webawesome switch component.
|
||||
Check the [webawesome documentation](https://webawesome.com/docs/components/switch/) for more details.
|
||||
|
||||
`switch-checked-color` / `switch-unchecked-color`
|
||||
Set both the color of the round handle and the track behind it. If you want to control them separately, use the variables below instead.
|
||||
**Properties/Attributes**
|
||||
|
||||
`switch-checked-button-color` / `switch-unchecked-button-color`
|
||||
Color of the round handle
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| checked | Boolean | false | The checked state of the switch. |
|
||||
| disabled | Boolean | false | Disables the switch and prevents user interaction. |
|
||||
| required | Boolean | false | Makes the switch a required field. |
|
||||
| haptic | Boolean | false | Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when save is required). |
|
||||
|
||||
`switch-checked-track-color` / `switch-unchecked-track-color`
|
||||
Color of the track behind the round handle
|
||||
**CSS Custom Properties**
|
||||
|
||||
- `--ha-switch-size` - The size of the switch track height. Defaults to `24px`.
|
||||
- `--ha-switch-thumb-size` - The size of the thumb. Defaults to `18px`.
|
||||
- `--ha-switch-width` - The width of the switch track. Defaults to `48px`.
|
||||
- `--ha-switch-thumb-box-shadow` - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
|
||||
- `--ha-switch-background-color` - Background color of the unchecked track.
|
||||
- `--ha-switch-thumb-background-color` - Background color of the unchecked thumb.
|
||||
- `--ha-switch-background-color-hover` - Background color of the unchecked track on hover.
|
||||
- `--ha-switch-thumb-background-color-hover` - Background color of the unchecked thumb on hover.
|
||||
- `--ha-switch-border-color` - Border color of the unchecked track.
|
||||
- `--ha-switch-thumb-border-color` - Border color of the unchecked thumb.
|
||||
- `--ha-switch-thumb-border-color-hover` - Border color of the unchecked thumb on hover.
|
||||
- `--ha-switch-checked-background-color` - Background color of the checked track.
|
||||
- `--ha-switch-checked-thumb-background-color` - Background color of the checked thumb.
|
||||
- `--ha-switch-checked-background-color-hover` - Background color of the checked track on hover.
|
||||
- `--ha-switch-checked-thumb-background-color-hover` - Background color of the checked thumb on hover.
|
||||
- `--ha-switch-checked-border-color` - Border color of the checked track.
|
||||
- `--ha-switch-checked-thumb-border-color` - Border color of the checked thumb.
|
||||
- `--ha-switch-checked-border-color-hover` - Border color of the checked track on hover.
|
||||
- `--ha-switch-checked-thumb-border-color-hover` - Border color of the checked thumb on hover.
|
||||
- `--ha-switch-disabled-opacity` - Opacity of the switch when disabled. Defaults to `0.2`.
|
||||
- `--ha-switch-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
- `--ha-switch-required-marker-offset` - Offset of the required marker. Defaults to `0.1rem`.
|
||||
|
||||
@@ -1 +1,95 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
@customElement("demo-components-ha-switch")
|
||||
export class DemoHaSwitch extends LitElement {
|
||||
@property({ attribute: false }) hass!: HomeAssistant;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-switch ${mode}">
|
||||
<div class="card-content">
|
||||
<div class="row">
|
||||
<span>Unchecked</span>
|
||||
<ha-switch></ha-switch>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Checked</span>
|
||||
<ha-switch checked></ha-switch>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Disabled</span>
|
||||
<ha-switch disabled></ha-switch>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Disabled checked</span>
|
||||
<ha-switch disabled checked></ha-switch>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
margin: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-switch": DemoHaSwitch;
|
||||
}
|
||||
}
|
||||
|
||||
73
gallery/src/pages/components/ha-textarea.markdown
Normal file
73
gallery/src/pages/components/ha-textarea.markdown
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Textarea
|
||||
---
|
||||
|
||||
# Textarea `<ha-textarea>`
|
||||
|
||||
A multiline text input component supporting Home Assistant theming and validation, based on webawesome textarea.
|
||||
Supports autogrow, hints, validation, and both material and outlined appearances.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Example usage
|
||||
|
||||
```html
|
||||
<ha-textarea label="Description" value="Hello world"></ha-textarea>
|
||||
|
||||
<ha-textarea
|
||||
label="Notes"
|
||||
placeholder="Type here..."
|
||||
resize="auto"
|
||||
></ha-textarea>
|
||||
|
||||
<ha-textarea label="Required field" required></ha-textarea>
|
||||
|
||||
<ha-textarea label="Disabled" disabled value="Can't edit this"></ha-textarea>
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome textarea component.
|
||||
|
||||
**Slots**
|
||||
|
||||
- `label`: Custom label content. Overrides the `label` property.
|
||||
- `hint`: Custom hint content. Overrides the `hint` property.
|
||||
|
||||
**Properties/Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------------ | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| value | String | - | The current value of the textarea. |
|
||||
| label | String | "" | The textarea's label text. |
|
||||
| hint | String | "" | The textarea's hint/helper text. |
|
||||
| placeholder | String | "" | Placeholder text shown when the textarea is empty. |
|
||||
| rows | Number | 4 | The number of visible text rows. |
|
||||
| resize | "none"/"vertical"/"horizontal"/"both"/"auto" | "none" | Controls the textarea's resize behavior. |
|
||||
| readonly | Boolean | false | Makes the textarea readonly. |
|
||||
| disabled | Boolean | false | Disables the textarea and prevents user interaction. |
|
||||
| required | Boolean | false | Makes the textarea a required field. |
|
||||
| auto-validate | Boolean | false | Validates the textarea on blur instead of on form submit. |
|
||||
| invalid | Boolean | false | Marks the textarea as invalid. |
|
||||
| validation-message | String | "" | Custom validation message shown when the textarea is invalid. |
|
||||
| minlength | Number | - | The minimum length of input that will be considered valid. |
|
||||
| maxlength | Number | - | The maximum length of input that will be considered valid. |
|
||||
| name | String | - | The name of the textarea, submitted as a name/value pair with form data. |
|
||||
| autocapitalize | "off"/"none"/"on"/"sentences"/"words"/"characters" | "" | Controls whether and how text input is automatically capitalized. |
|
||||
| autocomplete | String | - | Indicates whether the browser's autocomplete feature should be used. |
|
||||
| autofocus | Boolean | false | Automatically focuses the textarea when the page loads. |
|
||||
| spellcheck | Boolean | true | Enables or disables the browser's spellcheck feature. |
|
||||
| inputmode | "none"/"text"/"decimal"/"numeric"/"tel"/"search"/"email"/"url" | "" | Hints at the type of data for showing an appropriate virtual keyboard. |
|
||||
| enterkeyhint | "enter"/"done"/"go"/"next"/"previous"/"search"/"send" | "" | Customizes the label or icon of the Enter key on virtual keyboards. |
|
||||
|
||||
#### CSS Parts
|
||||
|
||||
- `wa-base` - The underlying wa-textarea base wrapper.
|
||||
- `wa-hint` - The underlying wa-textarea hint container.
|
||||
- `wa-textarea` - The underlying wa-textarea textarea element.
|
||||
|
||||
**CSS Custom Properties**
|
||||
|
||||
- `--ha-textarea-padding-bottom` - Padding below the textarea host.
|
||||
- `--ha-textarea-max-height` - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
|
||||
- `--ha-textarea-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
151
gallery/src/pages/components/ha-textarea.ts
Normal file
151
gallery/src/pages/components/ha-textarea.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-textarea";
|
||||
|
||||
@customElement("demo-components-ha-textarea")
|
||||
export class DemoHaTextarea extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-textarea in ${mode}">
|
||||
<div class="card-content">
|
||||
<h3>Basic</h3>
|
||||
<div class="row">
|
||||
<ha-textarea label="Default"></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With value"
|
||||
value="Hello world"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With placeholder"
|
||||
placeholder="Type here..."
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>Autogrow</h3>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
label="Autogrow empty"
|
||||
resize="auto"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="Autogrow with value"
|
||||
resize="auto"
|
||||
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>States</h3>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
label="Disabled"
|
||||
disabled
|
||||
value="Disabled"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="Readonly"
|
||||
readonly
|
||||
value="Readonly"
|
||||
></ha-textarea>
|
||||
<ha-textarea label="Required" required></ha-textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
label="Invalid"
|
||||
invalid
|
||||
validation-message="This field is required"
|
||||
value=""
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With hint"
|
||||
hint="Supports Markdown"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
label="With rows"
|
||||
.rows=${6}
|
||||
placeholder="6 rows"
|
||||
></ha-textarea>
|
||||
</div>
|
||||
|
||||
<h3>No label</h3>
|
||||
<div class="row">
|
||||
<ha-textarea
|
||||
placeholder="No label, just placeholder"
|
||||
></ha-textarea>
|
||||
<ha-textarea
|
||||
resize="auto"
|
||||
placeholder="No label, autogrow"
|
||||
></ha-textarea>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
h3 {
|
||||
margin: var(--ha-space-4) 0 var(--ha-space-1) 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
.row > * {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-textarea": DemoHaTextarea;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ The Home Assistant interface is based on Material Design. It's a design system c
|
||||
We want to make it as easy for designers to contribute as it is for developers. There’s a lot a designer can contribute to:
|
||||
|
||||
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Start designing with our <a href="https://www.figma.com/community/file/967153512097289521/Home-Assistant-DesignKit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
|
||||
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
|
||||
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
|
||||
|
||||
## Developers
|
||||
|
||||
@@ -134,6 +134,53 @@ const CONFIGS = [
|
||||
entity: sensor.not_working
|
||||
`,
|
||||
},
|
||||
{
|
||||
heading: "Lower minimum",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
needle: true
|
||||
severity:
|
||||
green: 0
|
||||
yellow: 0.45
|
||||
red: 0.9
|
||||
min: -0.05
|
||||
name: " "
|
||||
max: 1.9
|
||||
unit: GBP/h`,
|
||||
},
|
||||
{
|
||||
heading: "A lot of segments",
|
||||
config: `
|
||||
- type: gauge
|
||||
needle: true
|
||||
name: Percent gauge
|
||||
entity: sensor.brightness_high
|
||||
unit: "%"
|
||||
min: 0
|
||||
max: 100
|
||||
segments:
|
||||
- from: 0
|
||||
color: "#db4437"
|
||||
- from: 10
|
||||
color: "#cc4d39"
|
||||
- from: 20
|
||||
color: "#bd563a"
|
||||
- from: 30
|
||||
color: "#ad603c"
|
||||
- from: 40
|
||||
color: "#9e693d"
|
||||
- from: 50
|
||||
color: "#8f723f"
|
||||
- from: 60
|
||||
color: "#807b41"
|
||||
- from: 70
|
||||
color: "#718442"
|
||||
- from: 80
|
||||
color: "#618e44"
|
||||
- from: 90
|
||||
color: "#43a047"`,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-lovelace-gauge-card")
|
||||
|
||||
3
gallery/src/pages/misc/box-shadow.markdown
Normal file
3
gallery/src/pages/misc/box-shadow.markdown
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Box shadow
|
||||
---
|
||||
98
gallery/src/pages/misc/box-shadow.ts
Normal file
98
gallery/src/pages/misc/box-shadow.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
|
||||
const SHADOWS = ["s", "m", "l"] as const;
|
||||
|
||||
@customElement("demo-misc-box-shadow")
|
||||
export class DemoMiscBoxShadow extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<h2>${mode}</h2>
|
||||
<div class="grid">
|
||||
${SHADOWS.map(
|
||||
(size) => html`
|
||||
<div
|
||||
class="box"
|
||||
style="box-shadow: var(--ha-box-shadow-${size})"
|
||||
>
|
||||
${size}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.light,
|
||||
.dark {
|
||||
flex: 1;
|
||||
background-color: var(--primary-background-color);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-text-color);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-misc-box-shadow": DemoMiscBoxShadow;
|
||||
}
|
||||
}
|
||||
@@ -422,7 +422,6 @@ export class DemoEntityState extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-data-table
|
||||
.hass=${this.hass}
|
||||
.columns=${this._columns(this.hass)}
|
||||
.data=${this._rows()}
|
||||
auto-height
|
||||
|
||||
3
gallery/src/pages/more-info/lawn-mower.markdown
Normal file
3
gallery/src/pages/more-info/lawn-mower.markdown
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Lawn mower
|
||||
---
|
||||
98
gallery/src/pages/more-info/lawn-mower.ts
Normal file
98
gallery/src/pages/more-info/lawn-mower.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/dialogs/more-info/more-info-content";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-more-infos";
|
||||
import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
|
||||
|
||||
const ALL_FEATURES =
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.DOCK;
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "lawn_mower.full_featured",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "Full featured mower",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.mowing",
|
||||
state: "mowing",
|
||||
attributes: {
|
||||
friendly_name: "Mowing",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.returning",
|
||||
state: "returning",
|
||||
attributes: {
|
||||
friendly_name: "Returning",
|
||||
supported_features:
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.DOCK,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.paused",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.error",
|
||||
state: "error",
|
||||
attributes: {
|
||||
friendly_name: "Error",
|
||||
supported_features:
|
||||
LawnMowerEntityFeature.START_MOWING + LawnMowerEntityFeature.DOCK,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.basic",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "Basic mower",
|
||||
supported_features: LawnMowerEntityFeature.START_MOWING,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-lawn-mower")
|
||||
class DemoMoreInfoLawnMower extends LitElement {
|
||||
@property({ attribute: false }) public hass!: MockHomeAssistant;
|
||||
|
||||
@query("demo-more-infos") private _demoRoot!: HTMLElement;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<demo-more-infos
|
||||
.hass=${this.hass}
|
||||
.entities=${ENTITIES.map((ent) => ent.entity_id)}
|
||||
></demo-more-infos>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-more-info-lawn-mower": DemoMoreInfoLawnMower;
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,101 @@ import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-more-infos";
|
||||
import { VacuumEntityFeature } from "../../../../src/data/vacuum";
|
||||
|
||||
const ALL_FEATURES =
|
||||
VacuumEntityFeature.STATE +
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
VacuumEntityFeature.CLEAN_AREA;
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "vacuum.first_floor_vacuum",
|
||||
entity_id: "vacuum.full_featured",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "First floor vacuum",
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.cleaning_vacuum",
|
||||
state: "cleaning",
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.returning_vacuum",
|
||||
state: "returning",
|
||||
attributes: {
|
||||
friendly_name: "Returning vacuum",
|
||||
supported_features:
|
||||
VacuumEntityFeature.STATE +
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.error_vacuum",
|
||||
state: "error",
|
||||
attributes: {
|
||||
friendly_name: "Error vacuum",
|
||||
supported_features:
|
||||
VacuumEntityFeature.STATE +
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.LOCATE,
|
||||
status: "Stuck on obstacle",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.basic_vacuum",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "Basic vacuum",
|
||||
supported_features:
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.paused_vacuum",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-vacuum")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import "@material/mwc-linear-progress/mwc-linear-progress";
|
||||
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
|
||||
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
|
||||
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "@material/mwc-linear-progress";
|
||||
import { mdiOpenInNew } from "@mdi/js";
|
||||
import { css, html, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -8,6 +7,7 @@ import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-fade-in";
|
||||
import "../../src/components/ha-spinner";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/progress/ha-progress-bar";
|
||||
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
|
||||
import "../../src/onboarding/onboarding-welcome-links";
|
||||
import { onBoardingStyles } from "../../src/onboarding/styles";
|
||||
@@ -60,7 +60,7 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
${!networkIssue && !this._supervisorError
|
||||
? html`
|
||||
<p>${this.localize("subheader")}</p>
|
||||
<mwc-linear-progress indeterminate></mwc-linear-progress>
|
||||
<ha-progress-bar indeterminate></ha-progress-bar>
|
||||
`
|
||||
: nothing}
|
||||
${networkIssue || this._networkInfoError
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
"*.?(c|m){js,ts}": [
|
||||
"eslint --flag v10_config_lookup_from_file --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
|
||||
"eslint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
|
||||
"prettier --cache --write",
|
||||
"lit-analyzer --quiet",
|
||||
],
|
||||
|
||||
97
package.json
97
package.json
@@ -8,8 +8,8 @@
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "script/build_frontend",
|
||||
"lint:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
|
||||
"format:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "tsc",
|
||||
@@ -30,22 +30,23 @@
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.40.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.3.1",
|
||||
"@formatjs/intl-displaynames": "7.3.1",
|
||||
"@formatjs/intl-durationformat": "0.10.3",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.2",
|
||||
"@formatjs/intl-listformat": "8.3.1",
|
||||
"@formatjs/intl-locale": "5.3.1",
|
||||
"@formatjs/intl-numberformat": "9.3.1",
|
||||
"@formatjs/intl-pluralrules": "6.3.1",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.1",
|
||||
"@formatjs/intl-datetimeformat": "7.3.2",
|
||||
"@formatjs/intl-displaynames": "7.3.2",
|
||||
"@formatjs/intl-durationformat": "0.10.4",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.3",
|
||||
"@formatjs/intl-listformat": "8.3.2",
|
||||
"@formatjs/intl-locale": "5.3.2",
|
||||
"@formatjs/intl-numberformat": "9.3.2",
|
||||
"@formatjs/intl-pluralrules": "6.3.2",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.2",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -59,22 +60,12 @@
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/mwc-base": "0.27.0",
|
||||
"@material/mwc-checkbox": "0.27.0",
|
||||
"@material/mwc-dialog": "0.27.0",
|
||||
"@material/mwc-drawer": "0.27.0",
|
||||
"@material/mwc-fab": "0.27.0",
|
||||
"@material/mwc-floating-label": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-linear-progress": "0.27.0",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/mwc-radio": "0.27.0",
|
||||
"@material/mwc-select": "0.27.0",
|
||||
"@material/mwc-switch": "0.27.0",
|
||||
"@material/mwc-textarea": "0.27.0",
|
||||
"@material/mwc-textfield": "0.27.0",
|
||||
"@material/mwc-top-app-bar": "0.27.0",
|
||||
"@material/mwc-top-app-bar-fixed": "0.27.0",
|
||||
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
|
||||
@@ -82,14 +73,14 @@
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "3.9.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.1.1",
|
||||
"barcode-detector": "3.1.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
@@ -102,13 +93,13 @@
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.0.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.1.0",
|
||||
"fuse.js": "7.3.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.15",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.0",
|
||||
"intl-messageformat": "11.2.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -116,7 +107,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "17.0.5",
|
||||
"marked": "18.0.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -129,7 +120,6 @@
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "3.0.0",
|
||||
"ua-parser-js": "2.0.9",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
@@ -144,17 +134,19 @@
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.0",
|
||||
"@html-eslint/eslint-plugin": "0.58.1",
|
||||
"@lokalise/node-api": "15.6.1",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/eslintrc": "3.3.5",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.59.0",
|
||||
"@lokalise/node-api": "15.7.1",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.5",
|
||||
"@rspack/core": "1.7.9",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "1.7.11",
|
||||
"@rspack/dev-server": "1.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.25",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
@@ -169,18 +161,18 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.0",
|
||||
"@vitest/coverage-v8": "4.1.4",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "9.39.4",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
@@ -188,13 +180,14 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.5.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.0.1",
|
||||
"jsdom": "29.0.2",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
@@ -202,17 +195,17 @@
|
||||
"lodash.template": "4.5.0",
|
||||
"map-stream": "0.0.7",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.3",
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "21.0.3",
|
||||
"tar": "7.5.12",
|
||||
"sinon": "21.1.2",
|
||||
"tar": "7.5.13",
|
||||
"terser-webpack-plugin": "5.4.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.58.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.0",
|
||||
"vitest": "4.1.4",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
|
||||
@@ -223,13 +216,13 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"globals": "17.4.0",
|
||||
"globals": "17.5.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.13.0",
|
||||
"packageManager": "yarn@4.14.1",
|
||||
"volta": {
|
||||
"node": "24.14.1"
|
||||
"node": "24.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260128.0"
|
||||
version = "20260325.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -9,7 +9,6 @@ import "../components/ha-alert";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-checkbox";
|
||||
import { computeInitialHaFormData } from "../components/ha-form/compute-initial-ha-form-data";
|
||||
import "../components/ha-formfield";
|
||||
import type { AuthProvider } from "../data/auth";
|
||||
import {
|
||||
autocompleteLoginFields,
|
||||
@@ -97,11 +96,6 @@ export class HaAuthFlow extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<style>
|
||||
ha-auth-flow .store-token {
|
||||
margin-left: -16px;
|
||||
margin-inline-start: -16px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
a.forgot-password {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
@@ -121,6 +115,9 @@ export class HaAuthFlow extends LitElement {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.action {
|
||||
margin-top: var(--ha-space-5);
|
||||
}
|
||||
.action ha-button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -249,17 +246,12 @@ export class HaAuthFlow extends LitElement {
|
||||
${this.clientId === genClientId() &&
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? html`
|
||||
<ha-formfield
|
||||
class="store-token"
|
||||
.label=${this.localize(
|
||||
"ui.panel.page-authorize.store_token"
|
||||
)}
|
||||
<ha-checkbox
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
>
|
||||
<ha-checkbox
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
></ha-checkbox>
|
||||
</ha-formfield>
|
||||
${this.localize("ui.panel.page-authorize.store_token")}
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""}
|
||||
<a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable no-console */
|
||||
/// <reference types="chromecast-caf-sender" />
|
||||
|
||||
import type { Auth } from "home-assistant-js-websocket";
|
||||
import { castApiAvailable } from "./cast_framework";
|
||||
|
||||
@@ -32,6 +32,12 @@ const YAML_ONLY_THEMES_COLORS = new Set([
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Compose a CSS variable out of a theme color
|
||||
* @param color - Theme color (examples: `red`, `primary-text`)
|
||||
* @returns CSS variable in `--xxx-color` format;
|
||||
* initial color if not found in theme colors
|
||||
*/
|
||||
export function computeCssVariableName(color: string): string {
|
||||
if (THEME_COLORS.has(color) || YAML_ONLY_THEMES_COLORS.has(color)) {
|
||||
return `--${color}-color`;
|
||||
@@ -39,6 +45,12 @@ export function computeCssVariableName(color: string): string {
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a CSS variable out of a theme color & then resolve it
|
||||
* @param color - Theme color (examples: `red`, `primary-text`)
|
||||
* @returns Resolved CSS variable in `var(--xxx-color)` format;
|
||||
* initial color if not found in theme colors
|
||||
*/
|
||||
export function computeCssColor(color: string): string {
|
||||
const cssVarName = computeCssVariableName(color);
|
||||
if (cssVarName !== color) {
|
||||
@@ -47,6 +59,22 @@ export function computeCssColor(color: string): string {
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a color from document's styles
|
||||
* @param color - Named theme color (examples: `red`, `primary-text`)
|
||||
* @returns Resolved color; initial color if not found in document's styles
|
||||
*/
|
||||
export function resolveThemeColor(color: string): string {
|
||||
const cssColor = computeCssVariableName(color);
|
||||
if (cssColor.startsWith("--")) {
|
||||
const resolved = getComputedStyle(document.body)
|
||||
.getPropertyValue(cssColor)
|
||||
.trim();
|
||||
return resolved || color;
|
||||
}
|
||||
return cssColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid color.
|
||||
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import colors from "color-name";
|
||||
import { expandHex } from "./hex";
|
||||
import { resolveThemeColor } from "./compute-color";
|
||||
|
||||
const rgb_hex = (component: number): string => {
|
||||
const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16);
|
||||
@@ -130,26 +131,43 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
|
||||
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
|
||||
hsv2rgb([hs[0], hs[1], 255]);
|
||||
|
||||
export function theme2hex(themeColor: string): string {
|
||||
if (themeColor.startsWith("#")) {
|
||||
if (themeColor.length === 4 || themeColor.length === 5) {
|
||||
const c = themeColor;
|
||||
/**
|
||||
* Attempt to get a HEX color from a color defined in different formats:
|
||||
* HEX, rgb/rgba, named color
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to be converted to HEX
|
||||
* @returns HEX color
|
||||
*/
|
||||
export function theme2hex(color: string): string {
|
||||
// Attempting to find a HEX pattern in the input string
|
||||
if (color.startsWith("#")) {
|
||||
if (color.length === 4 || color.length === 5) {
|
||||
const c = color;
|
||||
// Convert short-form hex (#abc) to 6 digit (#aabbcc). Ignore alpha channel.
|
||||
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
|
||||
}
|
||||
if (themeColor.length === 9) {
|
||||
if (color.length === 9) {
|
||||
// Ignore alpha channel.
|
||||
return themeColor.substring(0, 7);
|
||||
return color.substring(0, 7);
|
||||
}
|
||||
return themeColor;
|
||||
return color;
|
||||
}
|
||||
|
||||
const rgbFromColorName = colors[themeColor.toLowerCase()];
|
||||
if (rgbFromColorName) {
|
||||
return rgb2hex(rgbFromColorName);
|
||||
// Attempting to find a match in a HA Frontend theme colors
|
||||
const themeColor = resolveThemeColor(color.toLowerCase());
|
||||
if (themeColor !== color.toLowerCase()) {
|
||||
// theme color is recognized, now re-attempt
|
||||
return theme2hex(themeColor);
|
||||
}
|
||||
|
||||
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
// Attempting to find a match in a web colors array
|
||||
const rgbFromWebColor = colors[color.toLowerCase()];
|
||||
if (rgbFromWebColor) {
|
||||
// HEX color is recognized for the input named color
|
||||
return rgb2hex(rgbFromWebColor);
|
||||
}
|
||||
|
||||
// Attempting to find an RGB pattern in the input string
|
||||
const rgbMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return rgb2hex([r, g, b]);
|
||||
@@ -158,5 +176,5 @@ export function theme2hex(themeColor: string): string {
|
||||
// We have a named color, and there's nothing in the table,
|
||||
// so nothing further we can do with it.
|
||||
// Compare/border/background color will all be the same.
|
||||
return themeColor;
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { wcagLuminance, wcagContrast } from "culori";
|
||||
import { theme2hex } from "./convert-color";
|
||||
|
||||
/**
|
||||
* Calculates the luminosity of an RGB color.
|
||||
@@ -48,3 +49,13 @@ export const getRGBContrastRatio = (
|
||||
rgb1: [number, number, number],
|
||||
rgb2: [number, number, number]
|
||||
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Returns a contrasted color (black or white) based on the luminance of another color
|
||||
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
|
||||
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
|
||||
*/
|
||||
export const getContrastedColorHex = (color: string): string => {
|
||||
const lum = wcagLuminance(theme2hex(color));
|
||||
return lum > 0.5 ? "#000000" : "#ffffff";
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Condition } from "../../panels/lovelace/common/validate-condition";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
@@ -19,7 +22,8 @@ export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
|
||||
@@ -36,7 +40,8 @@ export function setupMediaQueryListeners(
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
});
|
||||
@@ -51,7 +56,8 @@ export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
|
||||
@@ -70,7 +76,8 @@ export function setupTimeListeners(
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
@@ -87,3 +94,17 @@ export function setupTimeListeners(
|
||||
scheduleUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all condition listeners (media query, time) for conditional visibility.
|
||||
*/
|
||||
export function setupConditionListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const isLoadedIntegration = (
|
||||
) =>
|
||||
!page.component ||
|
||||
ensureArray(page.component).some((integration) =>
|
||||
isComponentLoaded(hass, integration)
|
||||
isComponentLoaded(hass.config, integration)
|
||||
);
|
||||
|
||||
export const isNotLoadedIntegration = (
|
||||
@@ -23,7 +23,7 @@ export const isNotLoadedIntegration = (
|
||||
) =>
|
||||
!page.not_component ||
|
||||
!ensureArray(page.not_component).some((integration) =>
|
||||
isComponentLoaded(hass, integration)
|
||||
isComponentLoaded(hass.config, integration)
|
||||
);
|
||||
|
||||
export const isCore = (page: PageNavigation) => page.core;
|
||||
|
||||
@@ -21,6 +21,9 @@ export const filterNavigationPages = (
|
||||
if (page.path === "#external-app-configuration") {
|
||||
return hass.auth.external?.config.hasSettingsScreen;
|
||||
}
|
||||
if (page.adminOnly && !hass.user?.is_admin) {
|
||||
return false;
|
||||
}
|
||||
// Only show Bluetooth page if there are Bluetooth config entries
|
||||
if (page.component === "bluetooth") {
|
||||
return options.hasBluetoothConfigEntries ?? false;
|
||||
|
||||
@@ -2,6 +2,6 @@ import type { HomeAssistant } from "../../types";
|
||||
|
||||
/** Return if a component is loaded. */
|
||||
export const isComponentLoaded = (
|
||||
hass: HomeAssistant,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
component: string
|
||||
): boolean => hass && hass.config.components.includes(component);
|
||||
): boolean => hassConfig && hassConfig.components.includes(component);
|
||||
|
||||
@@ -27,6 +27,7 @@ export type DateRange =
|
||||
| "this_year"
|
||||
| "now-7d"
|
||||
| "now-30d"
|
||||
| "now-365d"
|
||||
| "now-12m"
|
||||
| "now-1h"
|
||||
| "now-12h"
|
||||
@@ -102,6 +103,11 @@ export const calcDateRange = (
|
||||
),
|
||||
calcDate(today, endOfMonth, locale, hassConfig),
|
||||
];
|
||||
case "now-365d":
|
||||
return [
|
||||
calcDate(today, subDays, locale, hassConfig, 365),
|
||||
calcDate(today, subDays, locale, hassConfig, 0),
|
||||
];
|
||||
case "now-1h":
|
||||
return [
|
||||
calcDate(today, subHours, locale, hassConfig, 1),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DurationInput } from "@formatjs/intl-durationformat/src/types";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
@@ -114,7 +113,7 @@ export const formatDuration = (
|
||||
case "d": {
|
||||
const days = Math.floor(value);
|
||||
const hours = Math.floor((value - days) * 24);
|
||||
const input: DurationInput = {
|
||||
const input = {
|
||||
days,
|
||||
hours,
|
||||
};
|
||||
@@ -123,7 +122,7 @@ export const formatDuration = (
|
||||
case "h": {
|
||||
const hours = Math.floor(value);
|
||||
const minutes = Math.floor((value - hours) * 60);
|
||||
const input: DurationInput = {
|
||||
const input = {
|
||||
hours,
|
||||
minutes,
|
||||
};
|
||||
@@ -132,7 +131,7 @@ export const formatDuration = (
|
||||
case "min": {
|
||||
const minutes = Math.floor(value);
|
||||
const seconds = Math.floor((value - minutes) * 60);
|
||||
const input: DurationInput = {
|
||||
const input = {
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,14 @@ export interface HASSDomEvent<T> extends Event {
|
||||
detail: T;
|
||||
}
|
||||
|
||||
export type HASSDomTargetEvent<T extends EventTarget> = Event & {
|
||||
target: T;
|
||||
};
|
||||
|
||||
export type HASSDomCurrentTargetEvent<T extends EventTarget> = Event & {
|
||||
currentTarget: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches a custom event with an optional detail value.
|
||||
*
|
||||
|
||||
@@ -7,7 +7,8 @@ export type LeafletModuleType = typeof import("leaflet");
|
||||
export type LeafletDrawModuleType = typeof import("leaflet-draw");
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement
|
||||
mapElement: HTMLElement,
|
||||
initialView?: { latitude: number; longitude: number; zoom?: number }
|
||||
): Promise<[Map, LeafletModuleType, TileLayer]> => {
|
||||
if (!mapElement.parentNode) {
|
||||
throw new Error("Cannot setup Leaflet map on disconnected element");
|
||||
@@ -32,7 +33,12 @@ export const setupLeafletMap = async (
|
||||
markerClusterStyle.setAttribute("rel", "stylesheet");
|
||||
mapElement.parentNode.appendChild(markerClusterStyle);
|
||||
|
||||
map.setView([52.3731339, 4.8903147], 13);
|
||||
if (initialView) {
|
||||
map.setView(
|
||||
[initialView.latitude, initialView.longitude],
|
||||
initialView.zoom ?? 13
|
||||
);
|
||||
}
|
||||
|
||||
const tileLayer = createTileLayer(Leaflet).addTo(map);
|
||||
|
||||
|
||||
@@ -14,24 +14,25 @@ export const computeDeviceName = (
|
||||
|
||||
export const computeDeviceNameDisplay = (
|
||||
device: DeviceRegistryEntry,
|
||||
hass: HomeAssistant,
|
||||
localize: HomeAssistant["localize"],
|
||||
hassStates: HomeAssistant["states"],
|
||||
entities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
|
||||
) =>
|
||||
computeDeviceName(device) ||
|
||||
(entities && fallbackDeviceName(hass, entities)) ||
|
||||
hass.localize("ui.panel.config.devices.unnamed_device", {
|
||||
type: hass.localize(
|
||||
(entities && fallbackDeviceName(hassStates, entities)) ||
|
||||
localize("ui.panel.config.devices.unnamed_device", {
|
||||
type: localize(
|
||||
`ui.panel.config.devices.type.${device.entry_type || "device"}`
|
||||
),
|
||||
});
|
||||
|
||||
export const fallbackDeviceName = (
|
||||
hass: HomeAssistant,
|
||||
hassStates: HomeAssistant["states"],
|
||||
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
|
||||
) => {
|
||||
for (const entity of entities || []) {
|
||||
const entityId = typeof entity === "string" ? entity : entity.entity_id;
|
||||
const stateObj = hass.states[entityId];
|
||||
const stateObj = hassStates[entityId];
|
||||
if (stateObj) {
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
EntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
|
||||
|
||||
export const computeEntityName = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"]
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): string | undefined => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
@@ -18,22 +21,49 @@ export const computeEntityName = (
|
||||
// Fall back to state name if not in the entity registry (friendly name)
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
return computeEntityEntryName(entry);
|
||||
return computeEntityEntryName(entry, devices);
|
||||
};
|
||||
|
||||
export const computeEntityEntryName = (
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
|
||||
devices: HomeAssistant["devices"],
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
if (entry.name != null) {
|
||||
return entry.name;
|
||||
const name =
|
||||
entry.name ||
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
|
||||
const device = entry.device_id ? devices[entry.device_id] : undefined;
|
||||
|
||||
if (!device) {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (fallbackStateObj) {
|
||||
return computeStateName(fallbackStateObj);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if ("original_name" in entry && entry.original_name != null) {
|
||||
return String(entry.original_name);
|
||||
|
||||
const deviceName = computeDeviceName(device);
|
||||
|
||||
// If the device name is the same as the entity name, consider empty entity name
|
||||
if (deviceName === name) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// Remove the device name from the entity name if it starts with it
|
||||
if (deviceName && name) {
|
||||
return stripPrefixFromEntityName(name, deviceName) || name;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
export const entityUseDeviceName = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"]
|
||||
): boolean => !computeEntityName(stateObj, entities);
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): boolean => !computeEntityName(stateObj, entities, devices);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computeAreaName } from "./compute_area_name";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
|
||||
import { computeFloorName } from "./compute_floor_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { getEntityContext } from "./context/get_entity_context";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
@@ -29,14 +30,23 @@ export interface EntityNameOptions {
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[] | undefined,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameOptions
|
||||
) => {
|
||||
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
|
||||
// If no name config is provided, fall back to the friendly name
|
||||
if (!name) {
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
|
||||
let items = ensureArray(name);
|
||||
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
@@ -45,7 +55,7 @@ export const computeEntityNameDisplay = (
|
||||
return items.map((item) => item.text).join(separator);
|
||||
}
|
||||
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities);
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
|
||||
|
||||
// If entity uses device name, and device is not already included, replace it with device name
|
||||
if (useDeviceName) {
|
||||
@@ -91,7 +101,7 @@ export const computeEntityNameList = (
|
||||
const names = name.map((item) => {
|
||||
switch (item.type) {
|
||||
case "entity":
|
||||
return computeEntityName(stateObj, entities);
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "area":
|
||||
|
||||
@@ -142,9 +142,10 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
minusSign: "value",
|
||||
};
|
||||
|
||||
const valueParts: ValuePart[] = [];
|
||||
@@ -153,7 +154,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
const type = TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
|
||||
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
|
||||
if (type === "value" && last?.type === "value") {
|
||||
last.value += part.value;
|
||||
} else {
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
interface DeviceContext {
|
||||
device: DeviceRegistryEntry;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const getDeviceContext = (
|
||||
export const getDeviceArea = (
|
||||
device: DeviceRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
): DeviceContext => {
|
||||
areas: HomeAssistant["areas"]
|
||||
): AreaRegistryEntry | undefined => {
|
||||
const areaId = device.area_id;
|
||||
const area = areaId ? hass.areas[areaId] : undefined;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : undefined;
|
||||
|
||||
return {
|
||||
device: device,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
return areaId ? areas[areaId] : undefined;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ export const isDeletableEntity = (
|
||||
const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
|
||||
if (isHelperDomain(domain)) {
|
||||
return !!(
|
||||
isComponentLoaded(hass, domain) &&
|
||||
isComponentLoaded(hass.config, domain) &&
|
||||
entityRegEntry &&
|
||||
fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
|
||||
);
|
||||
@@ -56,7 +56,7 @@ export const deleteEntity = (
|
||||
const domain = computeDomain(entity_id);
|
||||
const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
|
||||
if (isHelperDomain(domain)) {
|
||||
if (isComponentLoaded(hass, domain)) {
|
||||
if (isComponentLoaded(hass.config, domain)) {
|
||||
if (
|
||||
entityRegEntry &&
|
||||
fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
|
||||
|
||||
@@ -242,14 +242,18 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
},
|
||||
};
|
||||
|
||||
export const getStates = (
|
||||
export const getStatesDomain = (
|
||||
hass: HomeAssistant,
|
||||
state: HassEntity,
|
||||
attribute: string | undefined = undefined
|
||||
domain: string,
|
||||
attribute?: string | undefined
|
||||
): string[] => {
|
||||
const domain = computeStateDomain(state);
|
||||
const result: string[] = [];
|
||||
|
||||
if (!attribute) {
|
||||
// All entities can have unavailable states
|
||||
result.push(...UNAVAILABLE_STATES);
|
||||
}
|
||||
|
||||
if (!attribute && domain in FIXED_DOMAIN_STATES) {
|
||||
result.push(...FIXED_DOMAIN_STATES[domain]);
|
||||
} else if (
|
||||
@@ -260,19 +264,7 @@ export const getStates = (
|
||||
result.push(...FIXED_DOMAIN_ATTRIBUTE_STATES[domain][attribute]);
|
||||
}
|
||||
|
||||
// Dynamic values based on the entities
|
||||
switch (domain) {
|
||||
case "climate":
|
||||
if (!attribute) {
|
||||
result.push(...state.attributes.hvac_modes);
|
||||
} else if (attribute === "fan_mode") {
|
||||
result.push(...state.attributes.fan_modes);
|
||||
} else if (attribute === "preset_mode") {
|
||||
result.push(...state.attributes.preset_modes);
|
||||
} else if (attribute === "swing_mode") {
|
||||
result.push(...state.attributes.swing_modes);
|
||||
}
|
||||
break;
|
||||
case "device_tracker":
|
||||
case "person":
|
||||
if (!attribute) {
|
||||
@@ -291,6 +283,37 @@ export const getStates = (
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getStates = (
|
||||
hass: HomeAssistant,
|
||||
state: HassEntity,
|
||||
attribute: string | undefined = undefined
|
||||
): string[] => {
|
||||
const domain = computeStateDomain(state);
|
||||
const result: string[] = [];
|
||||
|
||||
// Fixed values based on a domain
|
||||
result.push(...getStatesDomain(hass, domain, attribute));
|
||||
|
||||
// Dynamic values based on the entities
|
||||
switch (domain) {
|
||||
case "climate":
|
||||
if (!attribute) {
|
||||
result.push(...state.attributes.hvac_modes);
|
||||
} else if (attribute === "fan_mode") {
|
||||
result.push(...state.attributes.fan_modes);
|
||||
} else if (attribute === "preset_mode") {
|
||||
result.push(...state.attributes.preset_modes);
|
||||
} else if (attribute === "swing_mode") {
|
||||
result.push(...state.attributes.swing_modes);
|
||||
} else if (attribute === "swing_horizontal_mode") {
|
||||
result.push(...state.attributes.swing_horizontal_modes);
|
||||
}
|
||||
break;
|
||||
case "event":
|
||||
if (attribute === "event_type") {
|
||||
result.push(...state.attributes.event_types);
|
||||
@@ -351,9 +374,5 @@ export const getStates = (
|
||||
break;
|
||||
}
|
||||
|
||||
if (!attribute) {
|
||||
// All entities can have unavailable states
|
||||
result.push(...UNAVAILABLE_STATES);
|
||||
}
|
||||
return [...new Set(result)];
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
case "person":
|
||||
return compareState !== "not_home";
|
||||
case "lawn_mower":
|
||||
return ["mowing", "error"].includes(compareState);
|
||||
return !["docked", "paused"].includes(compareState);
|
||||
case "lock":
|
||||
return compareState !== "locked";
|
||||
case "media_player":
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Indicates whether the current browser has native ElementInternals support.
|
||||
*/
|
||||
export const nativeElementInternalsSupported =
|
||||
Boolean(globalThis.ElementInternals) &&
|
||||
globalThis.HTMLElement?.prototype.attachInternals
|
||||
?.toString()
|
||||
.includes("[native code]");
|
||||
11
src/common/feature-detect/support-popover.ts
Normal file
11
src/common/feature-detect/support-popover.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Indicates whether the current browser supports the Popover API.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
|
||||
*/
|
||||
export const popoverSupported = globalThis?.HTMLElement?.prototype
|
||||
? Object.prototype.hasOwnProperty.call(
|
||||
globalThis.HTMLElement.prototype,
|
||||
"popover"
|
||||
)
|
||||
: false;
|
||||
@@ -41,7 +41,7 @@ export const protocolIntegrationPicked = async (
|
||||
).filter((e) => !e.disabled_by);
|
||||
|
||||
if (
|
||||
!isComponentLoaded(hass, "zwave_js") ||
|
||||
!isComponentLoaded(hass.config, "zwave_js") ||
|
||||
(!options?.config_entry && !entries?.length)
|
||||
) {
|
||||
// If the component isn't loaded, ask them to load the integration first
|
||||
@@ -90,7 +90,7 @@ export const protocolIntegrationPicked = async (
|
||||
).filter((e) => !e.disabled_by);
|
||||
|
||||
if (
|
||||
!isComponentLoaded(hass, "zha") ||
|
||||
!isComponentLoaded(hass.config, "zha") ||
|
||||
(!options?.config_entry && !entries?.length)
|
||||
) {
|
||||
// If the component isn't loaded, ask them to load the integration first
|
||||
@@ -139,7 +139,7 @@ export const protocolIntegrationPicked = async (
|
||||
})
|
||||
).filter((e) => !e.disabled_by);
|
||||
if (
|
||||
!isComponentLoaded(hass, domain) ||
|
||||
!isComponentLoaded(hass.config, domain) ||
|
||||
(!options?.config_entry && !entries?.length)
|
||||
) {
|
||||
// If the component isn't loaded, ask them to load the integration first
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
*
|
||||
* @see https://github.com/home-assistant/frontend/issues/28732
|
||||
*/
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { directive, Directive } from "lit-html/directive.js";
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { setCommittedValue } from "lit-html/directive-helpers.js";
|
||||
// eslint-disable-next-line lit/no-legacy-imports
|
||||
import { nothing } from "lit-html";
|
||||
// eslint-disable-next-line import/extensions
|
||||
import type { Part } from "lit-html/directive.js";
|
||||
|
||||
class KeyedES5 extends Directive {
|
||||
|
||||
@@ -71,13 +71,6 @@ export const formatNumberToParts = (
|
||||
? numberFormatToLocale(localeOptions)
|
||||
: undefined;
|
||||
|
||||
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
|
||||
Number.isNaN =
|
||||
Number.isNaN ||
|
||||
function isNaN(input) {
|
||||
return typeof input === "number" && isNaN(input);
|
||||
};
|
||||
|
||||
if (
|
||||
localeOptions?.number_format !== NumberFormat.none &&
|
||||
!Number.isNaN(Number(num))
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
Collection,
|
||||
Connection,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
|
||||
export const subscribeOne = async <T>(
|
||||
conn: Connection,
|
||||
@@ -13,3 +17,11 @@ export const subscribeOne = async <T>(
|
||||
resolve(items);
|
||||
});
|
||||
});
|
||||
|
||||
export const subscribeOneCollection = async <T>(collection: Collection<T>) =>
|
||||
new Promise<T>((resolve) => {
|
||||
const unsub = collection.subscribe((data) => {
|
||||
unsub();
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,41 @@ import {
|
||||
formatDateMonthYear,
|
||||
formatDateVeryShort,
|
||||
formatDateWeekdayShort,
|
||||
formatDateYear,
|
||||
} from "../../common/datetime/format_date";
|
||||
import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../../common/datetime/format_time";
|
||||
|
||||
export function getPeriodicAxisLabelConfig(
|
||||
period: string,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
):
|
||||
| {
|
||||
formatter: (value: number) => string;
|
||||
}
|
||||
| undefined {
|
||||
if (period === "month") {
|
||||
return {
|
||||
formatter: (value: number) => {
|
||||
const date = new Date(value);
|
||||
return date.getMonth() === 0
|
||||
? `{bold|${formatDateMonthYear(date, locale, config)}}`
|
||||
: formatDateMonth(date, locale, config);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (period === "year") {
|
||||
return {
|
||||
formatter: (value: number) =>
|
||||
formatDateYear(new Date(value), locale, config),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatTimeLabel(
|
||||
value: number | Date,
|
||||
locale: FrontendLocaleData,
|
||||
|
||||
@@ -18,15 +18,16 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { getAllGraphColors } from "../../common/color/colors";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { themesContext } from "../../data/context";
|
||||
import { uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantUI } from "../../types";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button";
|
||||
@@ -74,8 +75,11 @@ export class HaChartBase extends LitElement {
|
||||
public extraComponents?: any[];
|
||||
|
||||
@state()
|
||||
@consume({ context: themesContext, subscribe: true })
|
||||
_themes!: Themes;
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({
|
||||
transformer: ({ themes }) => themes,
|
||||
})
|
||||
private _themes!: Themes;
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
@@ -91,6 +95,10 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _lastTapTime?: number;
|
||||
|
||||
private _longPressTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _longPressTriggered = false;
|
||||
|
||||
private _shouldResizeChart = false;
|
||||
|
||||
private _resizeAnimationDuration?: number;
|
||||
@@ -128,6 +136,7 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._legendPointerCancel();
|
||||
this._pendingSetup = false;
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
@@ -169,6 +178,7 @@ export class HaChartBase extends LitElement {
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
this._updateSankeyRoam();
|
||||
// drag to zoom
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
@@ -187,6 +197,7 @@ export class HaChartBase extends LitElement {
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
this._updateSankeyRoam();
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
@@ -262,6 +273,9 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
if (Object.keys(chartOptions).length > 0) {
|
||||
this._setChartOptions(chartOptions);
|
||||
if (chartOptions.series) {
|
||||
this._updateSankeyRoam();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,22 +316,31 @@ export class HaChartBase extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderLegend() {
|
||||
private _getLegendItems() {
|
||||
if (!this.options?.legend || !this.data) {
|
||||
return nothing;
|
||||
return undefined;
|
||||
}
|
||||
const legend = ensureArray(this.options.legend).find(
|
||||
(l) => l.show && l.type === "custom"
|
||||
) as CustomLegendOption | undefined;
|
||||
if (!legend) {
|
||||
return nothing;
|
||||
return undefined;
|
||||
}
|
||||
const datasets = ensureArray(this.data);
|
||||
const items =
|
||||
return (
|
||||
legend.data ||
|
||||
datasets
|
||||
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
|
||||
.map((d) => ({ id: d.id, name: d.name }));
|
||||
.map((d) => ({ id: d.id, name: d.name }))
|
||||
);
|
||||
}
|
||||
|
||||
private _renderLegend() {
|
||||
const items = this._getLegendItems();
|
||||
if (!items) {
|
||||
return nothing;
|
||||
}
|
||||
const datasets = ensureArray(this.data!);
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
@@ -362,6 +385,11 @@ export class HaChartBase extends LitElement {
|
||||
return html`<li
|
||||
.id=${id}
|
||||
@click=${this._legendClick}
|
||||
@pointerdown=${this._legendPointerDown}
|
||||
@pointerup=${this._legendPointerCancel}
|
||||
@pointerleave=${this._legendPointerCancel}
|
||||
@pointercancel=${this._legendPointerCancel}
|
||||
@contextmenu=${this._legendContextMenu}
|
||||
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
|
||||
.title=${name}
|
||||
>
|
||||
@@ -432,6 +460,22 @@ export class HaChartBase extends LitElement {
|
||||
this.chart.on("click", (e: ECElementEvent) => {
|
||||
fireEvent(this, "chart-click", e);
|
||||
});
|
||||
this.chart.on("sankeyroam", () => {
|
||||
const option = this.chart!.getOption();
|
||||
const series = option.series as any[];
|
||||
const sankeySeries = series?.find((s: any) => s.type === "sankey");
|
||||
const zoomed = sankeySeries.zoom !== 1;
|
||||
this._isZoomed = zoomed;
|
||||
if (!zoomed) {
|
||||
// Reset center when fully zoomed out
|
||||
this.chart!.setOption({
|
||||
series: [{ id: sankeySeries.id, center: null }],
|
||||
});
|
||||
}
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
|
||||
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
|
||||
this.chart!.dispatchAction({ type: "downplay" });
|
||||
});
|
||||
|
||||
if (!this.options?.dataZoom) {
|
||||
this.chart.getZr().on("dblclick", this._handleClickZoom);
|
||||
@@ -530,6 +574,7 @@ export class HaChartBase extends LitElement {
|
||||
...this._createOptions(),
|
||||
series: this._getSeries(),
|
||||
});
|
||||
this._updateSankeyRoam();
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
@@ -587,10 +632,7 @@ export class HaChartBase extends LitElement {
|
||||
id: "dataZoom",
|
||||
type: "inside",
|
||||
orient: "horizontal",
|
||||
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
|
||||
// It rescales the Y-axis to the visible data while keeping one point
|
||||
// just outside each boundary to avoid line gaps at the zoom edges.
|
||||
filterMode: "boundaryFilter" as any,
|
||||
filterMode: this._getDataZoomFilterMode() as any,
|
||||
xAxisIndex: 0,
|
||||
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
@@ -598,6 +640,23 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
|
||||
// It rescales the Y-axis to the visible data while keeping one point
|
||||
// just outside each boundary to avoid line gaps at the zoom edges.
|
||||
// Use "filter" for bar charts since boundaryFilter causes rendering issues.
|
||||
// Use "weakFilter" for other types (e.g. custom/timeline) so bars
|
||||
// spanning the visible range boundary are kept.
|
||||
private _getDataZoomFilterMode(): string {
|
||||
const series = ensureArray(this.data);
|
||||
if (series.every((s) => s.type === "line")) {
|
||||
return "boundaryFilter";
|
||||
}
|
||||
if (series.some((s) => s.type === "bar")) {
|
||||
return "filter";
|
||||
}
|
||||
return "weakFilter";
|
||||
}
|
||||
|
||||
private _createOptions(): ECOption {
|
||||
let xAxis = this.options?.xAxis;
|
||||
if (xAxis) {
|
||||
@@ -632,7 +691,7 @@ export class HaChartBase extends LitElement {
|
||||
hideOverlap: true,
|
||||
...axis.axisLabel,
|
||||
},
|
||||
minInterval,
|
||||
minInterval: axis.minInterval ?? minInterval,
|
||||
} as XAXisOption;
|
||||
});
|
||||
}
|
||||
@@ -955,6 +1014,26 @@ export class HaChartBase extends LitElement {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
// Handle sankey chart double-click zoom
|
||||
const option = this.chart.getOption();
|
||||
const allSeries = option.series as any[];
|
||||
const sankeySeries = allSeries?.filter((s: any) => s.type === "sankey");
|
||||
if (sankeySeries?.length) {
|
||||
if (this._isZoomed) {
|
||||
this._handleZoomReset();
|
||||
} else {
|
||||
this.chart.setOption({
|
||||
series: sankeySeries.map((s: any) => ({
|
||||
id: s.id,
|
||||
zoom: 2,
|
||||
})),
|
||||
});
|
||||
this._isZoomed = true;
|
||||
}
|
||||
if (sankeySeries.length === allSeries?.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const range = this._isZoomed
|
||||
? [0, 100]
|
||||
: [
|
||||
@@ -979,6 +1058,37 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _handleZoomReset() {
|
||||
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
|
||||
// Reset sankey roam zoom
|
||||
const option = this.chart?.getOption();
|
||||
const sankeySeries = (option?.series as any[])?.filter(
|
||||
(s: any) => s.type === "sankey"
|
||||
);
|
||||
if (sankeySeries?.length) {
|
||||
this.chart?.setOption({
|
||||
series: sankeySeries.map((s: any) => ({
|
||||
id: s.id,
|
||||
zoom: 1,
|
||||
center: null,
|
||||
})),
|
||||
});
|
||||
this._isZoomed = false;
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
private _updateSankeyRoam() {
|
||||
const option = this.chart?.getOption();
|
||||
const sankeySeries = (option?.series as any[])?.filter(
|
||||
(s: any) => s.type === "sankey"
|
||||
);
|
||||
if (sankeySeries?.length) {
|
||||
this.chart?.setOption({
|
||||
series: sankeySeries.map((s: any) => ({
|
||||
id: s.id,
|
||||
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDataZoomEvent(e: any) {
|
||||
@@ -1022,11 +1132,52 @@ export class HaChartBase extends LitElement {
|
||||
fireEvent(this, "chart-zoom", { start, end });
|
||||
}
|
||||
|
||||
private _legendClick(ev: any) {
|
||||
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
|
||||
private _legendPointerDown(ev: PointerEvent) {
|
||||
// Mouse uses Ctrl/Cmd+click instead
|
||||
if (ev.pointerType === "mouse") {
|
||||
return;
|
||||
}
|
||||
const id = (ev.currentTarget as HTMLElement)?.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
this._longPressTriggered = false;
|
||||
this._longPressTimer = setTimeout(() => {
|
||||
this._longPressTriggered = true;
|
||||
this._longPressTimer = undefined;
|
||||
this._soloLegend(id);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private _legendPointerCancel() {
|
||||
if (this._longPressTimer) {
|
||||
clearTimeout(this._longPressTimer);
|
||||
this._longPressTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _legendContextMenu(ev: Event) {
|
||||
if (this._longPressTimer || this._longPressTriggered) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private _legendClick(ev: MouseEvent) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
const id = ev.currentTarget?.id;
|
||||
if (this._longPressTriggered) {
|
||||
this._longPressTriggered = false;
|
||||
return;
|
||||
}
|
||||
const id = (ev.currentTarget as HTMLElement)?.id;
|
||||
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
|
||||
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
|
||||
if (soloModifier) {
|
||||
this._soloLegend(id);
|
||||
return;
|
||||
}
|
||||
if (this._hiddenDatasets.has(id)) {
|
||||
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
|
||||
this._hiddenDatasets.delete(i)
|
||||
@@ -1041,6 +1192,60 @@ export class HaChartBase extends LitElement {
|
||||
this.requestUpdate("_hiddenDatasets");
|
||||
}
|
||||
|
||||
private _soloLegend(id: string) {
|
||||
const allIds = this._getAllLegendIds();
|
||||
const clickedIds = this._getAllIdsFromLegend(this.options, id);
|
||||
const otherIds = allIds.filter((i) => !clickedIds.includes(i));
|
||||
|
||||
const clickedIsOnlyVisible =
|
||||
clickedIds.every((i) => !this._hiddenDatasets.has(i)) &&
|
||||
otherIds.every((i) => this._hiddenDatasets.has(i));
|
||||
|
||||
if (clickedIsOnlyVisible) {
|
||||
// Already solo'd on this item — restore all series to visible
|
||||
for (const hiddenId of [...this._hiddenDatasets]) {
|
||||
this._hiddenDatasets.delete(hiddenId);
|
||||
fireEvent(this, "dataset-unhidden", { id: hiddenId });
|
||||
}
|
||||
} else {
|
||||
// Solo: hide every other series, unhide clicked if it was hidden
|
||||
for (const otherId of otherIds) {
|
||||
if (!this._hiddenDatasets.has(otherId)) {
|
||||
this._hiddenDatasets.add(otherId);
|
||||
fireEvent(this, "dataset-hidden", { id: otherId });
|
||||
}
|
||||
}
|
||||
for (const clickedId of clickedIds) {
|
||||
if (this._hiddenDatasets.has(clickedId)) {
|
||||
this._hiddenDatasets.delete(clickedId);
|
||||
fireEvent(this, "dataset-unhidden", { id: clickedId });
|
||||
}
|
||||
}
|
||||
}
|
||||
this.requestUpdate("_hiddenDatasets");
|
||||
}
|
||||
|
||||
private _getAllLegendIds(): string[] {
|
||||
const items = this._getLegendItems();
|
||||
if (!items) {
|
||||
return [];
|
||||
}
|
||||
const allIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
const primaryId =
|
||||
typeof item === "string"
|
||||
? item
|
||||
: ((item.id as string) ?? (item.name as string) ?? "");
|
||||
for (const expandedId of this._getAllIdsFromLegend(
|
||||
this.options,
|
||||
primaryId
|
||||
)) {
|
||||
allIds.add(expandedId);
|
||||
}
|
||||
}
|
||||
return [...allIds];
|
||||
}
|
||||
|
||||
private _toggleExpandedLegend() {
|
||||
this.expandLegend = !this.expandLegend;
|
||||
setTimeout(() => {
|
||||
@@ -1254,5 +1459,6 @@ declare global {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
"chart-sankeyroam": { zoom: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface NetworkData {
|
||||
categories?: { name: string; symbol: string }[];
|
||||
}
|
||||
|
||||
const PHYSICS_DISABLE_THRESHOLD = 512;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
let GraphChart: typeof import("echarts/lib/chart/graph/install");
|
||||
|
||||
@@ -94,7 +96,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _reducedMotion = false;
|
||||
|
||||
@state() private _physicsEnabled = true;
|
||||
@state() private _physicsEnabled?: boolean;
|
||||
|
||||
@state() private _showLabels = true;
|
||||
|
||||
@@ -122,6 +124,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
|
||||
this._physicsEnabled =
|
||||
this.data.nodes.length <= PHYSICS_DISABLE_THRESHOLD;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!GraphChart || !this.data.nodes?.length) {
|
||||
return nothing;
|
||||
@@ -138,7 +148,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.data=${this._getSeries(
|
||||
this.data,
|
||||
this._physicsEnabled,
|
||||
this._physicsEnabled ?? false,
|
||||
this._reducedMotion,
|
||||
this._showLabels,
|
||||
isMobile,
|
||||
|
||||
@@ -64,6 +64,8 @@ export class HaSankeyChart extends LitElement {
|
||||
|
||||
public chart?: EChartsType;
|
||||
|
||||
private _currentZoom = 1;
|
||||
|
||||
@state() private _sizeController = new ResizeController(this, {
|
||||
callback: (entries) => entries[0]?.contentRect,
|
||||
});
|
||||
@@ -84,11 +86,13 @@ export class HaSankeyChart extends LitElement {
|
||||
} as ECOption;
|
||||
|
||||
return html`<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._createData(this.data, this._sizeController.value?.width)}
|
||||
.options=${options}
|
||||
height="100%"
|
||||
.extraComponents=${[SankeyChart]}
|
||||
@chart-click=${this._handleChartClick}
|
||||
@chart-sankeyroam=${this._handleChartSankeyRoam}
|
||||
></ha-chart-base>`;
|
||||
}
|
||||
|
||||
@@ -109,6 +113,10 @@ export class HaSankeyChart extends LitElement {
|
||||
return null;
|
||||
};
|
||||
|
||||
private _handleChartSankeyRoam = (ev: CustomEvent) => {
|
||||
this._currentZoom = ev.detail.zoom;
|
||||
};
|
||||
|
||||
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
|
||||
const detail = ev.detail;
|
||||
// Only handle node clicks (not links)
|
||||
@@ -180,6 +188,7 @@ export class HaSankeyChart extends LitElement {
|
||||
})),
|
||||
links,
|
||||
draggable: false,
|
||||
scaleLimit: { min: 1, max: 4 },
|
||||
orient: this.vertical ? "vertical" : "horizontal",
|
||||
nodeWidth: 15,
|
||||
nodeGap: NODE_GAP,
|
||||
@@ -210,7 +219,7 @@ export class HaSankeyChart extends LitElement {
|
||||
""
|
||||
);
|
||||
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
|
||||
const availableWidth = params.rect.width + 6;
|
||||
const availableWidth = (params.rect.width + 6) * this._currentZoom;
|
||||
const fontSize = Math.min(
|
||||
FONT_SIZE,
|
||||
(availableWidth / wordWidth) * FONT_SIZE
|
||||
@@ -223,7 +232,7 @@ export class HaSankeyChart extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
const availableHeight = params.rect.height + 8; // account for the margin
|
||||
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
|
||||
const fontSize = Math.min(
|
||||
(availableHeight / params.labelRect.height) * FONT_SIZE,
|
||||
FONT_SIZE
|
||||
|
||||
103
src/components/chart/round-caps.ts
Normal file
103
src/components/chart/round-caps.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { BarSeriesOption } from "echarts/types/dist/shared";
|
||||
|
||||
export function fillDataGapsAndRoundCaps(
|
||||
datasets: BarSeriesOption[],
|
||||
stacked = true
|
||||
) {
|
||||
if (!stacked) {
|
||||
// For non-stacked charts, we can simply apply an overall border to each stack
|
||||
// to curve the top of the bar, and then override on any negative bars.
|
||||
datasets.forEach((dataset) => {
|
||||
// Add upper border radius to stack
|
||||
dataset.itemStyle = {
|
||||
...dataset.itemStyle,
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
};
|
||||
// And override any negative points to have bottom border curved
|
||||
for (let pointIdx = 0; pointIdx < dataset.data!.length; pointIdx++) {
|
||||
const dataPoint = dataset.data![pointIdx];
|
||||
const item: any =
|
||||
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||
? dataPoint
|
||||
: { value: dataPoint };
|
||||
if (item.value?.[1] < 0) {
|
||||
dataset.data![pointIdx] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderRadius: [0, 0, 4, 4],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For stacked charts, we need to carefully work through the data points in each
|
||||
// stack to ensure only the lowermost negative and uppermost positive values have
|
||||
// a curved border.
|
||||
const buckets = Array.from(
|
||||
new Set(
|
||||
datasets
|
||||
.map((dataset) =>
|
||||
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||
)
|
||||
.flat()
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
// make sure all datasets have the same buckets
|
||||
// otherwise the chart will render incorrectly in some cases
|
||||
buckets.forEach((bucket, index) => {
|
||||
const capRounded = {};
|
||||
const capRoundedNegative = {};
|
||||
for (let i = datasets.length - 1; i >= 0; i--) {
|
||||
const dataPoint = datasets[i].data![index];
|
||||
const item: any =
|
||||
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||
? dataPoint
|
||||
: { value: dataPoint };
|
||||
const x = item.value?.[0];
|
||||
const stack = datasets[i].stack ?? "";
|
||||
if (x === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, {
|
||||
value: [bucket, 0],
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
});
|
||||
} else if (item.value?.[1] === 0) {
|
||||
// remove the border for zero values or it will be rendered
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderWidth: 0,
|
||||
},
|
||||
};
|
||||
} else if (!capRounded[stack] && item.value?.[1] > 0) {
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
};
|
||||
capRounded[stack] = true;
|
||||
} else if (!capRoundedNegative[stack] && item.value?.[1] < 0) {
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderRadius: [0, 0, 4, 4],
|
||||
},
|
||||
};
|
||||
capRoundedNegative[stack] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,13 @@ const safeParseFloat = (value) => {
|
||||
return isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const CLIMATE_MODE_CONFIGS = [
|
||||
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
|
||||
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
|
||||
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
|
||||
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
|
||||
] as const;
|
||||
|
||||
@customElement("state-history-chart-line")
|
||||
export class StateHistoryChartLine extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -429,23 +436,18 @@ export class StateHistoryChartLine extends LitElement {
|
||||
(entityState) => entityState.attributes?.hvac_action
|
||||
);
|
||||
|
||||
const isHeating =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === "heat"
|
||||
: (entityState: LineChartState) => entityState.state === "heat";
|
||||
const isCooling =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === "cool"
|
||||
: (entityState: LineChartState) => entityState.state === "cool";
|
||||
|
||||
const hasHeat = states.states.some(isHeating);
|
||||
const hasCool = states.states.some(isCooling);
|
||||
const activeModes = CLIMATE_MODE_CONFIGS.map(
|
||||
({ mode, action, cssVar }) => {
|
||||
const isActive =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === mode
|
||||
: (entityState: LineChartState) => entityState.state === mode;
|
||||
return { action, cssVar, isActive };
|
||||
}
|
||||
).filter(({ isActive }) => states.states.some(isActive));
|
||||
// We differentiate between thermostats that have a target temperature
|
||||
// range versus ones that have just a target temperature
|
||||
|
||||
@@ -466,33 +468,19 @@ export class StateHistoryChartLine extends LitElement {
|
||||
"component.climate.entity_component._.state_attributes.current_temperature.name"
|
||||
)
|
||||
);
|
||||
if (hasHeat) {
|
||||
for (const { action, cssVar } of activeModes) {
|
||||
addDataSet(
|
||||
states.entity_id + "-heating",
|
||||
`${states.entity_id}-${action}`,
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.heating", { name: name })
|
||||
? this.hass.localize(`ui.card.climate.${action}`, {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
|
||||
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-climate-heat-color"),
|
||||
computedStyles.getPropertyValue(cssVar),
|
||||
true
|
||||
);
|
||||
// The "heating" series uses steppedArea to shade the area below the current
|
||||
// temperature when the thermostat is calling for heat.
|
||||
}
|
||||
if (hasCool) {
|
||||
addDataSet(
|
||||
states.entity_id + "-cooling",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.cooling", { name: name })
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-climate-cool-color"),
|
||||
true
|
||||
);
|
||||
// The "cooling" series uses steppedArea to shade the area below the current
|
||||
// temperature when the thermostat is calling for heat.
|
||||
}
|
||||
|
||||
if (hasTargetRange) {
|
||||
@@ -540,11 +528,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
entityState.attributes.current_temperature
|
||||
);
|
||||
const series = [curTemp];
|
||||
if (hasHeat) {
|
||||
series.push(isHeating(entityState) ? curTemp : null);
|
||||
}
|
||||
if (hasCool) {
|
||||
series.push(isCooling(entityState) ? curTemp : null);
|
||||
for (const { isActive } of activeModes) {
|
||||
series.push(isActive(entityState) ? curTemp : null);
|
||||
}
|
||||
if (hasTargetRange) {
|
||||
const targetHigh = safeParseFloat(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiRestart } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiRestart } from "@mdi/js";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type {
|
||||
@@ -12,12 +12,12 @@ import type {
|
||||
} from "../../data/history";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { StateHistoryChartLine } from "./state-history-chart-line";
|
||||
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
|
||||
import "../ha-fab";
|
||||
import "../ha-button";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-history-chart-line";
|
||||
import type { StateHistoryChartLine } from "./state-history-chart-line";
|
||||
import "./state-history-chart-timeline";
|
||||
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
|
||||
|
||||
const CANVAS_TIMELINE_ROWS_CHUNK = 10; // Split up the canvases to avoid hitting the render limit
|
||||
|
||||
@@ -105,7 +105,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
@restoreScroll(".container") private _savedScrollPos?: number;
|
||||
|
||||
protected render() {
|
||||
if (!isComponentLoaded(this.hass, "history")) {
|
||||
if (!isComponentLoaded(this.hass.config, "history")) {
|
||||
return html`<div class="info">
|
||||
${this.hass.localize("ui.components.history_charts.history_disabled")}
|
||||
</div>`;
|
||||
@@ -150,16 +150,14 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._renderHistoryItem(item, index)
|
||||
)}`}
|
||||
${this.syncCharts && this._hasZoomedCharts
|
||||
? html`<ha-fab
|
||||
slot="fab"
|
||||
? html`<ha-button
|
||||
size="large"
|
||||
class="reset-button"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.history_charts.zoom_reset"
|
||||
)}
|
||||
@click=${this._handleGlobalZoomReset}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
|
||||
</ha-fab>`
|
||||
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.history_charts.zoom_reset")}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
@@ -448,6 +446,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
bottom: calc(24px + var(--safe-area-inset-bottom));
|
||||
right: calc(24px + var(--safe-area-inset-bottom));
|
||||
z-index: 1;
|
||||
--ha-button-box-shadow: var(--ha-box-shadow-l);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
} from "../../data/recorder";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -66,7 +68,11 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
|
||||
|
||||
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
|
||||
@property({ attribute: false }) public chartType:
|
||||
| "line"
|
||||
| "line-stack"
|
||||
| "bar"
|
||||
| "bar-stack" = "line";
|
||||
|
||||
@property({ attribute: false }) public minYAxis?: number;
|
||||
|
||||
@@ -148,7 +154,7 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!isComponentLoaded(this.hass, "history")) {
|
||||
if (!isComponentLoaded(this.hass.config, "history")) {
|
||||
return html`<div class="info">
|
||||
${this.hass.localize("ui.components.history_charts.history_disabled")}
|
||||
</div>`;
|
||||
@@ -293,6 +299,22 @@ export class StatisticsChart extends LitElement {
|
||||
type: "time",
|
||||
min: startTime,
|
||||
max: this.endTime,
|
||||
...(this.period === "month" && {
|
||||
minInterval: 28 * 24 * 3600 * 1000,
|
||||
axisLabel: getPeriodicAxisLabelConfig(
|
||||
"month",
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}),
|
||||
...(this.period === "year" && {
|
||||
minInterval: 365 * 24 * 3600 * 1000,
|
||||
axisLabel: getPeriodicAxisLabelConfig(
|
||||
"year",
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "hiddenAxis",
|
||||
@@ -309,7 +331,7 @@ export class StatisticsChart extends LitElement {
|
||||
},
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
scale:
|
||||
this.chartType !== "bar" ||
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined,
|
||||
@@ -369,6 +391,8 @@ export class StatisticsChart extends LitElement {
|
||||
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
|
||||
|
||||
let colorIndex = 0;
|
||||
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
|
||||
const chartStacked = this.chartType.endsWith("stack");
|
||||
const statisticsData = Object.entries(this.statisticsData);
|
||||
const totalDataSets: typeof this._chartData = [];
|
||||
const legendData: {
|
||||
@@ -454,19 +478,17 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (
|
||||
this.chartType === "line" &&
|
||||
chartType === "line" &&
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push(
|
||||
this._transformDataValue([prevEndTime, ...prevValues[i]!])
|
||||
);
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = end;
|
||||
@@ -486,7 +508,8 @@ export class StatisticsChart extends LitElement {
|
||||
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
|
||||
const hasMin =
|
||||
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
|
||||
const drawBands = [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
|
||||
const drawBands =
|
||||
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
|
||||
|
||||
const hasState = this.statTypes.includes("state");
|
||||
|
||||
@@ -518,8 +541,8 @@ export class StatisticsChart extends LitElement {
|
||||
const backgroundColor = band ? color + "3F" : color + "7F";
|
||||
const series: LineSeriesOption | BarSeriesOption = {
|
||||
id: `${statistic_id}-${type}`,
|
||||
type: this.chartType,
|
||||
smooth: this.chartType === "line" ? 0.4 : false,
|
||||
type: chartType,
|
||||
smooth: chartType === "line" ? 0.4 : false,
|
||||
cursor: "default",
|
||||
data: [],
|
||||
name: name
|
||||
@@ -538,16 +561,23 @@ export class StatisticsChart extends LitElement {
|
||||
width: 1.5,
|
||||
},
|
||||
itemStyle:
|
||||
this.chartType === "bar"
|
||||
chartType === "bar"
|
||||
? {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
borderColor,
|
||||
borderWidth: 1.5,
|
||||
}
|
||||
: undefined,
|
||||
color: this.chartType === "bar" ? backgroundColor : borderColor,
|
||||
color: chartType === "bar" ? backgroundColor : borderColor,
|
||||
};
|
||||
if (band && this.chartType === "line") {
|
||||
if (chartStacked) {
|
||||
series.stack = `band-stacked`;
|
||||
series.stackStrategy = "samesign";
|
||||
if (chartType === "line") {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
} else if (band && chartType === "line") {
|
||||
series.stack = `band-${statistic_id}`;
|
||||
series.stackStrategy = "all";
|
||||
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
|
||||
@@ -604,7 +634,7 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
} else if (
|
||||
type === bandTop &&
|
||||
this.chartType === "line" &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
@@ -628,11 +658,9 @@ export class StatisticsChart extends LitElement {
|
||||
// For line charts, close out the last stat segment at prevEndTime
|
||||
const lastEndTime = prevEndTime;
|
||||
const lastValues = prevValues;
|
||||
if (this.chartType === "line" && lastEndTime && lastValues) {
|
||||
if (chartType === "line" && lastEndTime && lastValues) {
|
||||
statDataSets.forEach((d, i) => {
|
||||
d.data!.push(
|
||||
this._transformDataValue([lastEndTime, ...lastValues[i]!])
|
||||
);
|
||||
d.data!.push([lastEndTime, ...lastValues[i]!]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -640,6 +668,7 @@ export class StatisticsChart extends LitElement {
|
||||
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
|
||||
if (
|
||||
displayCurrentState &&
|
||||
!chartStacked &&
|
||||
(!this.unit || !statisticUnit || this.unit === statisticUnit)
|
||||
) {
|
||||
// Skip external statistics
|
||||
@@ -660,7 +689,7 @@ export class StatisticsChart extends LitElement {
|
||||
const val: (number | null)[] = [];
|
||||
if (
|
||||
type === bandTop &&
|
||||
this.chartType === "line" &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
@@ -670,9 +699,7 @@ export class StatisticsChart extends LitElement {
|
||||
} else {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push(
|
||||
this._transformDataValue([now, ...val])
|
||||
);
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -684,6 +711,13 @@ export class StatisticsChart extends LitElement {
|
||||
Array.prototype.push.apply(legendData, statLegendData);
|
||||
});
|
||||
|
||||
if (chartType === "bar") {
|
||||
fillDataGapsAndRoundCaps(
|
||||
totalDataSets as BarSeriesOption[],
|
||||
chartStacked
|
||||
);
|
||||
}
|
||||
|
||||
legendData.forEach(({ id, name, color, borderColor }) => {
|
||||
// Add an empty series for the legend
|
||||
totalDataSets.push({
|
||||
@@ -693,7 +727,7 @@ export class StatisticsChart extends LitElement {
|
||||
itemStyle: {
|
||||
borderColor,
|
||||
},
|
||||
type: this.chartType,
|
||||
type: chartType,
|
||||
data: [],
|
||||
xAxisIndex: 1,
|
||||
});
|
||||
@@ -711,13 +745,6 @@ export class StatisticsChart extends LitElement {
|
||||
this._statisticIds = statisticIds;
|
||||
}
|
||||
|
||||
private _transformDataValue(val: [Date, ...(number | null)[]]) {
|
||||
if (this.chartType === "bar" && val[1] && val[1] < 0) {
|
||||
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
if (this.logarithmicScale) {
|
||||
// log(0) is -Infinity, so we need to set a minimum value
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
@@ -53,16 +52,15 @@ class HaDataTableLabels extends LitElement {
|
||||
}
|
||||
|
||||
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
|
||||
const color = label?.color ? computeCssColor(label.color) : undefined;
|
||||
return html`
|
||||
<ha-label
|
||||
dense
|
||||
role="button"
|
||||
tabindex="0"
|
||||
.color=${label.color}
|
||||
.item=${label}
|
||||
@click=${clickAction ? this._labelClicked : undefined}
|
||||
@keydown=${clickAction ? this._labelClicked : undefined}
|
||||
style=${color ? `--color: ${color}` : ""}
|
||||
.description=${label.description}
|
||||
>
|
||||
${label?.icon
|
||||
@@ -102,10 +100,6 @@ class HaDataTableLabels extends LitElement {
|
||||
position: fixed;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
ha-label {
|
||||
--ha-label-background-color: var(--color, var(--grey-color));
|
||||
--ha-label-background-opacity: 0.5;
|
||||
}
|
||||
.plus {
|
||||
--ha-label-background-color: transparent;
|
||||
border: 1px solid var(--divider-color);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
@@ -15,15 +16,21 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import { deepActiveElement } from "../../common/dom/deep-active-element";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { groupBy } from "../../common/util/group-by";
|
||||
import { nextRender } from "../../common/util/render-status";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-svg-icon";
|
||||
@@ -101,12 +108,13 @@ export interface DataTableRowData {
|
||||
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
|
||||
|
||||
@customElement("ha-data-table")
|
||||
export class HaDataTable extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public localizeFunc?: LocalizeFunc;
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -160,6 +168,10 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@query("slot[name='header']") private _header!: HTMLSlotElement;
|
||||
|
||||
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
|
||||
|
||||
@query("lit-virtualizer") private _scroller?: HTMLElement;
|
||||
|
||||
@state() private _collapsedGroups: string[] = [];
|
||||
|
||||
@state() private _lastSelectedRowId: string | null = null;
|
||||
@@ -236,16 +248,30 @@ export class HaDataTable extends LitElement {
|
||||
this.updateComplete.then(() => this._calcTableHeight());
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
|
||||
if (!header) {
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (!this._headerRow) {
|
||||
return;
|
||||
}
|
||||
if (header.scrollWidth > header.clientWidth) {
|
||||
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
|
||||
|
||||
if (this._headerRow.scrollWidth > this._headerRow.clientWidth) {
|
||||
this.style.setProperty(
|
||||
"--table-row-width",
|
||||
`${this._headerRow.scrollWidth}px`
|
||||
);
|
||||
} else {
|
||||
this.style.removeProperty("--table-row-width");
|
||||
}
|
||||
|
||||
const activeElement = deepActiveElement();
|
||||
|
||||
if (
|
||||
changedProps.has("selectable") ||
|
||||
(!this.autoHeight &&
|
||||
activeElement &&
|
||||
AUTO_FOCUS_ALLOWED_ACTIVE_TAGS.includes(activeElement.tagName))
|
||||
) {
|
||||
this._focusScroller();
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(properties: PropertyValues) {
|
||||
@@ -378,8 +404,6 @@ export class HaDataTable extends LitElement {
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
|
||||
const columns = this._sortedColumns(this.columns, this.columnOrder);
|
||||
|
||||
const renderRow = (row: DataTableRowData, index: number) =>
|
||||
@@ -503,7 +527,10 @@ export class HaDataTable extends LitElement {
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div class="mdc-data-table__cell grows center" role="cell">
|
||||
${this.noDataText ||
|
||||
localize("ui.components.data-table.no-data")}
|
||||
this._i18n?.localize?.(
|
||||
"ui.components.data-table.no-data"
|
||||
) ||
|
||||
"No data"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,10 +539,12 @@ export class HaDataTable extends LitElement {
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
class="mdc-data-table__content scroller ha-scrollbar"
|
||||
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
|
||||
@scroll=${this._saveScrollPos}
|
||||
.items=${this._groupData(
|
||||
this._filteredData,
|
||||
localize,
|
||||
this._i18n?.localize,
|
||||
this._i18n?.locale,
|
||||
this.appendRow,
|
||||
this.groupColumn,
|
||||
this.groupOrder,
|
||||
@@ -685,7 +714,7 @@ export class HaDataTable extends LitElement {
|
||||
this._sortColumns[this.sortColumn],
|
||||
this.sortDirection,
|
||||
this.sortColumn,
|
||||
this.hass.locale.language
|
||||
this._i18n?.locale?.language
|
||||
)
|
||||
: filteredData;
|
||||
|
||||
@@ -711,7 +740,8 @@ export class HaDataTable extends LitElement {
|
||||
private _groupData = memoizeOne(
|
||||
(
|
||||
data: DataTableRowData[],
|
||||
localize: LocalizeFunc,
|
||||
localize: LocalizeFunc | undefined,
|
||||
locale: FrontendLocaleData | undefined,
|
||||
appendRow,
|
||||
groupColumn: string | undefined,
|
||||
groupOrder: string[] | undefined,
|
||||
@@ -735,11 +765,7 @@ export class HaDataTable extends LitElement {
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!groupOrder && isGroupSortColumn) {
|
||||
const comparison = stringCompare(
|
||||
a,
|
||||
b,
|
||||
this.hass.locale.language
|
||||
);
|
||||
const comparison = stringCompare(a, b, locale?.language);
|
||||
if (sortDirection === "asc") {
|
||||
return comparison;
|
||||
}
|
||||
@@ -760,7 +786,7 @@ export class HaDataTable extends LitElement {
|
||||
return stringCompare(
|
||||
["", "-", "—"].includes(a) ? "zzz" : a,
|
||||
["", "-", "—"].includes(b) ? "zzz" : b,
|
||||
this.hass.locale.language
|
||||
locale?.language
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
@@ -787,14 +813,15 @@ export class HaDataTable extends LitElement {
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronUp}
|
||||
.label=${this.hass.localize(
|
||||
.label=${localize?.(
|
||||
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
|
||||
)}
|
||||
) || (collapsed ? "Expand" : "Collapse")}
|
||||
class=${collapsed ? "collapsed" : ""}
|
||||
>
|
||||
</ha-icon-button>
|
||||
${groupName === UNDEFINED_GROUP_KEY
|
||||
? localize("ui.components.data-table.ungrouped")
|
||||
? localize?.("ui.components.data-table.ungrouped") ||
|
||||
"Ungrouped"
|
||||
: groupName || ""}
|
||||
</div>`,
|
||||
});
|
||||
@@ -825,8 +852,10 @@ export class HaDataTable extends LitElement {
|
||||
): Promise<DataTableRowData[]> => filterData(data, columns, filter)
|
||||
);
|
||||
|
||||
private _handleHeaderClick(ev: Event) {
|
||||
const columnId = (ev.currentTarget as any).columnId;
|
||||
private _handleHeaderClick(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { columnId: string }>
|
||||
) {
|
||||
const columnId = ev.currentTarget.columnId;
|
||||
if (!this.columns[columnId].sortable) {
|
||||
return;
|
||||
}
|
||||
@@ -844,11 +873,12 @@ export class HaDataTable extends LitElement {
|
||||
column: columnId,
|
||||
direction: this.sortDirection,
|
||||
});
|
||||
|
||||
this._focusScroller();
|
||||
}
|
||||
|
||||
private _handleHeaderRowCheckboxClick(ev: Event) {
|
||||
const checkbox = ev.target as HaCheckbox;
|
||||
if (checkbox.checked) {
|
||||
private _handleHeaderRowCheckboxClick(ev: HASSDomTargetEvent<HaCheckbox>) {
|
||||
if (ev.target.checked) {
|
||||
this.selectAll();
|
||||
} else {
|
||||
this._checkedRows = [];
|
||||
@@ -857,13 +887,25 @@ export class HaDataTable extends LitElement {
|
||||
this._lastSelectedRowId = null;
|
||||
}
|
||||
|
||||
private _handleRowCheckboxClicked = (ev: Event) => {
|
||||
const checkbox = ev.currentTarget as HaCheckbox;
|
||||
const rowId = (checkbox as any).rowId;
|
||||
private _handleRowCheckboxClicked = (ev: MouseEvent) => {
|
||||
// ha-checkbox label dispatches synthetic click on input, so handle the input click only
|
||||
if (!(ev.composedPath()[0] instanceof HTMLInputElement) && !ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In range select mode, use label click for Firefox since it doesn't fire input click events
|
||||
if (ev.composedPath()[0] instanceof HTMLInputElement && ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
const checkboxElement = ev.currentTarget as HaCheckbox & { rowId: string };
|
||||
|
||||
const rowId = checkboxElement.rowId;
|
||||
|
||||
const groupedData = this._groupData(
|
||||
this._filteredData,
|
||||
this.localizeFunc || this.hass.localize,
|
||||
this._i18n?.localize,
|
||||
this._i18n?.locale,
|
||||
this.appendRow,
|
||||
this.groupColumn,
|
||||
this.groupOrder,
|
||||
@@ -895,7 +937,7 @@ export class HaDataTable extends LitElement {
|
||||
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
|
||||
];
|
||||
}
|
||||
} else if (!checkbox.checked) {
|
||||
} else if (checkboxElement.checked) {
|
||||
if (!this._checkedRows.includes(rowId)) {
|
||||
this._checkedRows = [...this._checkedRows, rowId];
|
||||
}
|
||||
@@ -933,7 +975,9 @@ export class HaDataTable extends LitElement {
|
||||
return checkedRows;
|
||||
}
|
||||
|
||||
private _handleRowClick = (ev: Event) => {
|
||||
private _handleRowClick = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { rowId: string }>
|
||||
) => {
|
||||
if (
|
||||
ev
|
||||
.composedPath()
|
||||
@@ -949,14 +993,13 @@ export class HaDataTable extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rowId = (ev.currentTarget as any).rowId;
|
||||
const rowId = ev.currentTarget.rowId;
|
||||
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
|
||||
};
|
||||
|
||||
private _setTitle(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
if (target.scrollWidth > target.offsetWidth) {
|
||||
target.setAttribute("title", target.innerText);
|
||||
private _setTitle(ev: HASSDomCurrentTargetEvent<HTMLElement>) {
|
||||
if (ev.currentTarget.scrollWidth > ev.currentTarget.offsetWidth) {
|
||||
ev.currentTarget.setAttribute("title", ev.currentTarget.innerText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,6 +1021,12 @@ export class HaDataTable extends LitElement {
|
||||
this._debounceSearch((ev.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
private _focusScroller(): void {
|
||||
this._scroller?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async _calcTableHeight() {
|
||||
if (this.autoHeight) {
|
||||
return;
|
||||
@@ -987,23 +1036,27 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _saveScrollPos(e: Event) {
|
||||
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
|
||||
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
|
||||
this._savedScrollPos = e.target.scrollTop;
|
||||
|
||||
this.renderRoot.querySelector(".mdc-data-table__header-row")!.scrollLeft = (
|
||||
e.target as HTMLDivElement
|
||||
).scrollLeft;
|
||||
if (this._headerRow) {
|
||||
this._headerRow.scrollLeft = e.target.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _scrollContent(e: Event) {
|
||||
this.renderRoot.querySelector("lit-virtualizer")!.scrollLeft = (
|
||||
e.target as HTMLDivElement
|
||||
).scrollLeft;
|
||||
private _scrollContent(e: HASSDomTargetEvent<HTMLDivElement>) {
|
||||
if (!this._scroller) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scroller.scrollLeft = e.target.scrollLeft;
|
||||
}
|
||||
|
||||
private _collapseGroup = (ev: Event) => {
|
||||
const groupName = (ev.currentTarget as any).group;
|
||||
private _collapseGroup = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
|
||||
) => {
|
||||
const groupName = ev.currentTarget.group;
|
||||
if (this._collapsedGroups.includes(groupName)) {
|
||||
this._collapsedGroups = this._collapsedGroups.filter(
|
||||
(grp) => grp !== groupName
|
||||
@@ -1426,6 +1479,15 @@ export class HaDataTable extends LitElement {
|
||||
contain: size layout !important;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
lit-virtualizer:focus,
|
||||
lit-virtualizer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
ha-checkbox {
|
||||
padding: var(--ha-space-1);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import type { ActionDetail } from "@material/mwc-list";
|
||||
import { mdiCalendarToday } from "@mdi/js";
|
||||
import "cally";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
|
||||
import {
|
||||
formatCallyDateRange,
|
||||
@@ -12,14 +13,16 @@ import {
|
||||
formatDateYear,
|
||||
formatISODateOnly,
|
||||
} from "../../common/datetime/format_date";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
localeContext,
|
||||
localizeContext,
|
||||
} from "../../data/context";
|
||||
import { configContext, internationalizationContext } from "../../data/context";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import type { HomeAssistantConfig, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-filter-chip";
|
||||
import type { HaFilterChip } from "../chips/ha-filter-chip";
|
||||
import type { HaBaseTimeInput } from "../ha-base-time-input";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-icon-button-next";
|
||||
@@ -27,11 +30,12 @@ import "../ha-icon-button-prev";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../ha-time-input";
|
||||
import type { HaTimeInput } from "../ha-time-input";
|
||||
import type { DateRangePickerRanges } from "./ha-date-range-picker";
|
||||
import { datePickerStyles, dateRangePickerStyles } from "./styles";
|
||||
|
||||
@customElement("date-range-picker")
|
||||
export class DateRangePicker extends LitElement {
|
||||
export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
|
||||
|
||||
@property({ attribute: false }) public startDate?: Date;
|
||||
@@ -42,16 +46,15 @@ export class DateRangePicker extends LitElement {
|
||||
public timePicker = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: ContextType<typeof localizeContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: localeContext, subscribe: true })
|
||||
private locale!: ContextType<typeof localeContext>;
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private hassConfig!: ContextType<typeof configContext>;
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
|
||||
/** used to show month in calendar-range header */
|
||||
@state() private _pickerMonth?: string;
|
||||
@@ -69,6 +72,8 @@ export class DateRangePicker extends LitElement {
|
||||
to: { hours: 23, minutes: 59 },
|
||||
};
|
||||
|
||||
@queryAll("ha-time-input") private _timeInputs?: NodeListOf<HaTimeInput>;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -79,12 +84,20 @@ export class DateRangePicker extends LitElement {
|
||||
? formatCallyDateRange(
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
this._i18n?.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: undefined;
|
||||
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
|
||||
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
|
||||
if (this.timePicker && this.startDate && this.endDate) {
|
||||
this._timeValue = {
|
||||
@@ -100,26 +113,48 @@ export class DateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _renderRanges() {
|
||||
if (this._isMobileSize) {
|
||||
return html`
|
||||
<ha-chip-set class="ha-scrollbar">
|
||||
${Object.entries(this.ranges!).map(
|
||||
([name, range], index) => html`
|
||||
<ha-filter-chip
|
||||
.index=${index}
|
||||
.range=${range}
|
||||
@click=${this._clickDateRangeChip}
|
||||
>
|
||||
${name}
|
||||
</ha-filter-chip>
|
||||
`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list @action=${this._setDateRange} activatable>
|
||||
${Object.keys(this.ranges!).map(
|
||||
(name) => html`<ha-list-item>${name}</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="picker">
|
||||
${this.ranges !== false && this.ranges
|
||||
? html`<div class="date-range-ranges">
|
||||
<ha-list @action=${this._setDateRange} activatable>
|
||||
${Object.keys(this.ranges).map(
|
||||
(name) => html`<ha-list-item>${name}</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>`
|
||||
? html`<div class="date-range-ranges">${this._renderRanges()}</div>`
|
||||
: nothing}
|
||||
<div class="range">
|
||||
<calendar-range
|
||||
.value=${this._dateValue}
|
||||
.locale=${this.locale.language}
|
||||
.locale=${this._i18n.locale.language}
|
||||
.focusedDate=${this._focusDate}
|
||||
@focusday=${this._focusChanged}
|
||||
@change=${this._handleChange}
|
||||
show-outside-days
|
||||
.firstDayOfWeek=${firstWeekdayIndex(this.locale)}
|
||||
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
|
||||
>
|
||||
<ha-icon-button-prev
|
||||
tabindex="-1"
|
||||
@@ -132,7 +167,7 @@ export class DateRangePicker extends LitElement {
|
||||
<ha-icon-button
|
||||
@click=${this._focusToday}
|
||||
.path=${mdiCalendarToday}
|
||||
.label=${this.localize("ui.dialogs.date-picker.today")}
|
||||
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<ha-icon-button-next
|
||||
@@ -146,23 +181,25 @@ export class DateRangePicker extends LitElement {
|
||||
<div class="times">
|
||||
<ha-time-input
|
||||
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
|
||||
.locale=${this.locale}
|
||||
.locale=${this._i18n.locale}
|
||||
@value-changed=${this._handleChangeTime}
|
||||
.label=${this.localize(
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.time_from"
|
||||
)}
|
||||
id="from"
|
||||
placeholder-labels
|
||||
auto-validate
|
||||
></ha-time-input>
|
||||
<ha-time-input
|
||||
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
|
||||
.locale=${this.locale}
|
||||
.locale=${this._i18n.locale}
|
||||
@value-changed=${this._handleChangeTime}
|
||||
.label=${this.localize(
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.time_to"
|
||||
)}
|
||||
id="to"
|
||||
placeholder-labels
|
||||
auto-validate
|
||||
></ha-time-input>
|
||||
</div>
|
||||
`
|
||||
@@ -171,19 +208,33 @@ export class DateRangePicker extends LitElement {
|
||||
</div>
|
||||
<div class="footer">
|
||||
<ha-button appearance="plain" @click=${this._cancel}
|
||||
>${this.localize("ui.common.cancel")}</ha-button
|
||||
>${this._i18n.localize("ui.common.cancel")}</ha-button
|
||||
>
|
||||
<ha-button .disabled=${!this._dateValue} @click=${this._save}
|
||||
>${this.localize("ui.components.date-range-picker.select")}</ha-button
|
||||
>${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _focusToday() {
|
||||
const date = new Date();
|
||||
this._focusDate = formatISODateOnly(date, this.locale, this.hassConfig);
|
||||
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
|
||||
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
|
||||
this._focusDate = formatISODateOnly(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
}
|
||||
|
||||
private _cancel() {
|
||||
@@ -200,6 +251,14 @@ export class DateRangePicker extends LitElement {
|
||||
let endDate = new Date(`${dates[1]}T23:59:00`);
|
||||
|
||||
if (this.timePicker) {
|
||||
const timeInputs = this._timeInputs;
|
||||
if (
|
||||
timeInputs &&
|
||||
![...timeInputs].every((input) => input.reportValidity())
|
||||
) {
|
||||
// If we have time inputs, and they don't all report valid, don't save
|
||||
return;
|
||||
}
|
||||
startDate.setHours(this._timeValue.from.hours);
|
||||
startDate.setMinutes(this._timeValue.from.minutes);
|
||||
endDate.setHours(this._timeValue.to.hours);
|
||||
@@ -215,12 +274,12 @@ export class DateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.locale.time_zone === TimeZone.server) {
|
||||
if (this._i18n.locale.time_zone === TimeZone.server) {
|
||||
startDate = new Date(
|
||||
new TZDate(startDate, this.hassConfig.time_zone).getTime()
|
||||
new TZDate(startDate, this._hassConfig.time_zone).getTime()
|
||||
);
|
||||
endDate = new Date(
|
||||
new TZDate(endDate, this.hassConfig.time_zone).getTime()
|
||||
new TZDate(endDate, this._hassConfig.time_zone).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,8 +305,16 @@ export class DateRangePicker extends LitElement {
|
||||
|
||||
private _focusChanged(ev: CustomEvent<Date>) {
|
||||
const date = ev.detail;
|
||||
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
|
||||
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._focusDate = undefined;
|
||||
}
|
||||
|
||||
@@ -257,31 +324,38 @@ export class DateRangePicker extends LitElement {
|
||||
this._focusDate = undefined;
|
||||
}
|
||||
|
||||
private _clickDateRangeChip(ev: Event) {
|
||||
const chip = ev.target as HaFilterChip & {
|
||||
index: number;
|
||||
range: [Date, Date];
|
||||
};
|
||||
this._saveDateRangePreset(chip.range, chip.index);
|
||||
}
|
||||
|
||||
private _setDateRange(ev: CustomEvent<ActionDetail>) {
|
||||
const dateRange: [Date, Date] = Object.values(this.ranges!)[
|
||||
ev.detail.index
|
||||
];
|
||||
this._dateValue = formatCallyDateRange(
|
||||
dateRange[0],
|
||||
dateRange[1],
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
);
|
||||
this._saveDateRangePreset(dateRange, ev.detail.index);
|
||||
}
|
||||
|
||||
private _saveDateRangePreset(range: [Date, Date], index: number) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
startDate: range[0],
|
||||
endDate: range[1],
|
||||
},
|
||||
});
|
||||
fireEvent(this, "preset-selected", {
|
||||
index: ev.detail.index,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleChangeTime(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const time = ev.detail.value;
|
||||
const type = (ev.target as HaBaseTimeInput).id;
|
||||
const target = ev.target as HaBaseTimeInput;
|
||||
const type = target.id;
|
||||
if (time) {
|
||||
if (!this._timeValue) {
|
||||
this._timeValue = {
|
||||
@@ -298,20 +372,48 @@ export class DateRangePicker extends LitElement {
|
||||
static styles = [
|
||||
datePickerStyles,
|
||||
dateRangePickerStyles,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.date-range-ranges {
|
||||
border-right: 1px solid var(--divider-color);
|
||||
border-right: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
min-width: 140px;
|
||||
flex: 0 1 30%;
|
||||
}
|
||||
|
||||
.range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: var(--ha-space-3);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.picker {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-range-ranges {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
margin-top: var(--ha-space-5);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.range {
|
||||
flex-basis: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.times {
|
||||
@@ -326,12 +428,6 @@ export class DateRangePicker extends LitElement {
|
||||
padding: var(--ha-space-2);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.date-range-ranges {
|
||||
max-width: 30%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import "cally";
|
||||
import { isThisYear } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -14,12 +15,10 @@ import {
|
||||
formatShortDateTime,
|
||||
formatShortDateTimeWithYear,
|
||||
} from "../../common/datetime/format_date_time";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
localeContext,
|
||||
localizeContext,
|
||||
} from "../../data/context";
|
||||
import { configContext, internationalizationContext } from "../../data/context";
|
||||
import type { HomeAssistantConfig } from "../../types";
|
||||
import "../ha-bottom-sheet";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-icon-button-next";
|
||||
@@ -43,16 +42,15 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
|
||||
@customElement("ha-date-range-picker")
|
||||
export class HaDateRangePicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: ContextType<typeof localizeContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: localeContext, subscribe: true })
|
||||
private locale!: ContextType<typeof localeContext>;
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private hassConfig!: ContextType<typeof configContext>;
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
|
||||
@property({ attribute: false }) public startDate!: Date;
|
||||
|
||||
@@ -117,8 +115,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._ranges = {};
|
||||
rangeKeys.forEach((key) => {
|
||||
this._ranges![
|
||||
this.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this.locale, this.hassConfig, key);
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,47 +131,50 @@ export class HaDateRangePicker extends LitElement {
|
||||
${!this.minimal
|
||||
? html`<ha-textarea
|
||||
id="field"
|
||||
mobile-multiline
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${(isThisYear(this.startDate)
|
||||
? formatShortDateTime(
|
||||
this.startDate,
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.startDate,
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)) +
|
||||
(window.innerWidth >= 459 ? " - " : " - \n") +
|
||||
(isThisYear(this.endDate)
|
||||
? formatShortDateTime(
|
||||
this.endDate,
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.endDate,
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
))}
|
||||
.label=${this.localize(
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.start_date"
|
||||
) +
|
||||
" - " +
|
||||
this.localize("ui.components.date-range-picker.end_date")}
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.end_date"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this.localize("ui.common.previous")}
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this.localize("ui.common.next")}
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`
|
||||
@@ -181,7 +182,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this.localize(
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
@@ -289,8 +290,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
forward,
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this.startDate = start;
|
||||
this.endDate = end;
|
||||
@@ -336,14 +337,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
private _setTextareaFocusStyle(focused: boolean) {
|
||||
const textarea = this.renderRoot.querySelector("ha-textarea");
|
||||
if (textarea) {
|
||||
const foundation = (textarea as any).mdcFoundation;
|
||||
if (foundation) {
|
||||
if (focused) {
|
||||
foundation.activateFocus();
|
||||
} else {
|
||||
foundation.deactivateFocus();
|
||||
}
|
||||
}
|
||||
textarea.setFocused(focused);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiBackspace, mdiCalendarToday } from "@mdi/js";
|
||||
import "cally";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import {
|
||||
@@ -10,12 +11,10 @@ import {
|
||||
formatDateYear,
|
||||
formatISODateOnly,
|
||||
} from "../../common/datetime/format_date";
|
||||
import {
|
||||
configContext,
|
||||
localeContext,
|
||||
localizeContext,
|
||||
} from "../../data/context";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { configContext, internationalizationContext } from "../../data/context";
|
||||
import { DialogMixin } from "../../dialogs/dialog-mixin";
|
||||
import type { HomeAssistantConfig } from "../../types";
|
||||
import "../ha-button";
|
||||
import type { DatePickerDialogParams } from "../ha-date-input";
|
||||
import "../ha-dialog";
|
||||
@@ -40,16 +39,15 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: ContextType<typeof localizeContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: localeContext, subscribe: true })
|
||||
private locale!: ContextType<typeof localeContext>;
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private hassConfig!: ContextType<typeof configContext>;
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
|
||||
@state() private _value?: {
|
||||
year: string;
|
||||
@@ -74,14 +72,26 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
? new Date(`${this.params.value.split("T")[0]}T00:00:00`)
|
||||
: new Date();
|
||||
|
||||
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
|
||||
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
|
||||
this._value = this.params.value
|
||||
? {
|
||||
year: this._pickerYear,
|
||||
title: formatDateShort(date, this.locale, this.hassConfig),
|
||||
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
|
||||
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
|
||||
dateString: formatISODateOnly(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
@@ -95,7 +105,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
open
|
||||
width="small"
|
||||
.headerTitle=${this._value?.title ||
|
||||
this.localize("ui.dialogs.date-picker.title")}
|
||||
this._i18n.localize("ui.dialogs.date-picker.title")}
|
||||
.headerSubtitle=${this._value?.year}
|
||||
header-subtitle-position="above"
|
||||
>
|
||||
@@ -103,7 +113,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.path=${mdiBackspace}
|
||||
.label=${this.localize("ui.dialogs.date-picker.clear")}
|
||||
.label=${this._i18n.localize("ui.dialogs.date-picker.clear")}
|
||||
slot="headerActionItems"
|
||||
@click=${this._clear}
|
||||
></ha-icon-button>
|
||||
@@ -131,7 +141,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
<ha-icon-button
|
||||
@click=${this._setToday}
|
||||
.path=${mdiCalendarToday}
|
||||
.label=${this.localize("ui.dialogs.date-picker.today")}
|
||||
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<ha-icon-button-next tabindex="-1" slot="next"></ha-icon-button-next>
|
||||
@@ -143,10 +153,10 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.localize("ui.common.cancel")}
|
||||
${this._i18n.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._setValue}>
|
||||
${this.localize("ui.common.ok")}
|
||||
${this._i18n.localize("ui.common.ok")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>`;
|
||||
@@ -164,23 +174,39 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
? new Date(`${value.split("T")[0]}T00:00:00`)
|
||||
: new Date();
|
||||
this._value = {
|
||||
year: formatDateYear(date, this.locale, this.hassConfig),
|
||||
title: formatDateShort(date, this.locale, this.hassConfig),
|
||||
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
|
||||
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
|
||||
dateString:
|
||||
value || formatISODateOnly(date, this.locale, this.hassConfig),
|
||||
value || formatISODateOnly(date, this._i18n.locale, this._hassConfig),
|
||||
};
|
||||
|
||||
if (setFocusDay) {
|
||||
this._focusDate = this._value.dateString;
|
||||
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
|
||||
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _focusChanged(ev: CustomEvent<Date>) {
|
||||
const date = ev.detail;
|
||||
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
|
||||
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._focusDate = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,33 +79,8 @@ export const datePickerStyles = css`
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin-left: 48px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
calendar-month {
|
||||
min-height: calc(34px * 7);
|
||||
}
|
||||
calendar-month::part(day) {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
calendar-month::part(button) {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
calendar-month::part(range-inner),
|
||||
calendar-month::part(range-start),
|
||||
calendar-month::part(range-end),
|
||||
calendar-month::part(selected),
|
||||
calendar-month::part(selected):hover {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.month-year {
|
||||
margin-left: 40px;
|
||||
}
|
||||
margin-inline-start: 48px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceContext } from "../../common/entity/context/get_device_context";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
type DevicePickerItem,
|
||||
} from "../../data/device/device_picker";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
device: DeviceRegistryEntry
|
||||
@@ -154,7 +154,7 @@ export class HaDevicePicker extends LitElement {
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
|
||||
const { area } = getDeviceContext(device, this.hass);
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
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 {
|
||||
DEFAULT_ENTITY_NAME,
|
||||
type EntityNameItem,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
@@ -17,12 +15,14 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-button-toggle-group";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import "../ha-sortable";
|
||||
import "../input/ha-input";
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
@@ -73,10 +73,291 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
@state() private _mode?: "composed" | "custom";
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
const items = this._toItems(this.value);
|
||||
this._mode =
|
||||
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(_changedProperties: PropertyValues): void {
|
||||
if (this._mode === undefined) {
|
||||
const items = this._toItems(this.value);
|
||||
this._mode =
|
||||
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const modeButtons = [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.mode_composed"
|
||||
),
|
||||
value: "composed",
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.mode_custom"
|
||||
),
|
||||
value: "custom",
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
.buttons=${modeButtons}
|
||||
.active=${this._mode}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._modeChanged}
|
||||
></ha-button-toggle-group>
|
||||
</div>
|
||||
<div class="content">
|
||||
${this._mode === "custom"
|
||||
? this._renderTextInput()
|
||||
: this._renderPicker()}
|
||||
</div>
|
||||
</div>
|
||||
${this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTextInput() {
|
||||
const items = this._items;
|
||||
const value =
|
||||
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
|
||||
return html`
|
||||
<ha-input
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.value=${value}
|
||||
@input=${this._textInputChanged}
|
||||
></ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
const value = this._items;
|
||||
const validTypes = this._validTypes(this.entityId);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="field">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._items,
|
||||
(item) => item,
|
||||
(item: EntityNameItem, idx) => {
|
||||
const label = this._formatItem(item);
|
||||
const isValid = validTypes.has(item.type);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
<span>${label}</span>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _modeChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this._mode = ev.detail.value as "composed" | "custom";
|
||||
}
|
||||
|
||||
private _textInputChanged(ev: Event) {
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
const newValue: EntityNameItem[] = value
|
||||
? [{ type: "text", text: value }]
|
||||
: [];
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx || "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
const value = this._items[idx];
|
||||
// Pre-fill the field value when editing a text item
|
||||
if (value.type === "text" && value.text) {
|
||||
this._picker?.setFieldValue(value.text);
|
||||
}
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const value = this._items;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this._items];
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: EntityNameItem = parseOptionValue(value);
|
||||
|
||||
const newValue = [...this._items];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = item;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(item);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: EntityNameItem[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
private get _items(): EntityNameItem[] {
|
||||
return this._toItems(this.value);
|
||||
}
|
||||
|
||||
private _toItems = memoizeOne((value?: typeof this.value) => {
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
return [];
|
||||
}
|
||||
return [{ type: "text", text: value } satisfies EntityNameItem];
|
||||
}
|
||||
return value ? ensureArray(value) : [];
|
||||
});
|
||||
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
return item.type === "text" ? item.text : item;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _formatItem = (item: EntityNameItem) => {
|
||||
if (item.type === "text") {
|
||||
return `"${item.text}"`;
|
||||
}
|
||||
if (KNOWN_TYPES.has(item.type)) {
|
||||
return this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
|
||||
);
|
||||
}
|
||||
return item.type;
|
||||
};
|
||||
|
||||
private _validTypes = memoizeOne((entityId?: string) => {
|
||||
const options = new Set<string>(["text"]);
|
||||
if (!entityId) {
|
||||
@@ -161,157 +442,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
private _formatItem = (item: EntityNameItem) => {
|
||||
if (item.type === "text") {
|
||||
return `"${item.text}"`;
|
||||
}
|
||||
if (KNOWN_TYPES.has(item.type)) {
|
||||
return this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
|
||||
);
|
||||
}
|
||||
return item.type;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const value = this._items;
|
||||
const validTypes = this._validTypes(this.entityId);
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._items,
|
||||
(item) => item,
|
||||
(item: EntityNameItem, idx) => {
|
||||
const label = this._formatItem(item);
|
||||
const isValid = validTypes.has(item.type);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
<span>${label}</span>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx || "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
const value = this._items[idx];
|
||||
// Pre-fill the field value when editing a text item
|
||||
if (value.type === "text" && value.text) {
|
||||
this._picker?.setFieldValue(value.text);
|
||||
}
|
||||
}
|
||||
|
||||
private get _items(): EntityNameItem[] {
|
||||
return this._toItems(this.value);
|
||||
}
|
||||
|
||||
private _toItems = memoizeOne((value?: typeof this.value) => {
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
return [];
|
||||
}
|
||||
return [{ type: "text", text: value } satisfies EntityNameItem];
|
||||
}
|
||||
return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME];
|
||||
});
|
||||
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
return item.type === "text" ? item.text : item;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
const item = this._items[this._editIndex];
|
||||
@@ -362,58 +492,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const value = this._items;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this._items];
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: EntityNameItem = parseOptionValue(value);
|
||||
|
||||
const newValue = [...this._items];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = item;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(item);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: EntityNameItem[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
@@ -421,13 +499,42 @@ export class HaEntityNamePicker extends LitElement {
|
||||
}
|
||||
|
||||
.container {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
ha-generic-picker,
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
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 {
|
||||
.field:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -445,30 +552,25 @@ export class HaEntityNamePicker extends LitElement {
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
:host([disabled]) .container:after {
|
||||
:host([disabled]) .field:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
.container:focus-within:after {
|
||||
.field:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export class HaEntityStatePicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
@@ -124,7 +126,8 @@ export class HaEntityStatePicker extends LitElement {
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.disabled=${this.disabled || !this.entityId}
|
||||
.disabled=${this.disabled ||
|
||||
(!this.entityId && this.noEntity === false)}
|
||||
.autofocus=${this.autofocus}
|
||||
.required=${this.required}
|
||||
.label=${this.label ??
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-relative-time";
|
||||
import "./state-badge";
|
||||
import "../ha-tooltip";
|
||||
import "./state-badge";
|
||||
|
||||
@customElement("state-info")
|
||||
class StateInfo extends LitElement {
|
||||
@@ -22,7 +21,7 @@ class StateInfo extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const name = computeStateName(this.stateObj);
|
||||
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
|
||||
|
||||
return html`<state-badge
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -94,7 +94,7 @@ class HaAddonPicker extends LitElement {
|
||||
|
||||
private async _getApps() {
|
||||
try {
|
||||
if (isComponentLoaded(this.hass, "hassio")) {
|
||||
if (isComponentLoaded(this.hass.config, "hassio")) {
|
||||
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
|
||||
this._addons = addonsInfo.addons
|
||||
.filter((addon) => addon.version)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
@@ -38,6 +39,8 @@ class HaAlert extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public dismissable = false;
|
||||
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
public render() {
|
||||
@@ -65,7 +68,7 @@ class HaAlert extends LitElement {
|
||||
${this.dismissable
|
||||
? html`<ha-icon-button
|
||||
@click=${this._dismissClicked}
|
||||
label="Dismiss alert"
|
||||
.label=${this.localize!("ui.common.dismiss_alert")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
|
||||
@@ -21,7 +21,6 @@ class AliasesEditor extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-input-multi
|
||||
.hass=${this.hass}
|
||||
.value=${this.aliases}
|
||||
.disabled=${this.disabled}
|
||||
.sortable=${this.sortable}
|
||||
|
||||
@@ -267,7 +267,6 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
: item.domain
|
||||
? html`<ha-domain-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.domain=${item.domain}
|
||||
.deviceClass=${item.deviceClass}
|
||||
></ha-domain-icon>`
|
||||
|
||||
@@ -56,7 +56,10 @@ class HaAttributeValue extends LitElement {
|
||||
this.stateObj!,
|
||||
this.attribute
|
||||
);
|
||||
return parts.find((part) => part.type === "value")?.value;
|
||||
return parts
|
||||
.filter((part) => part.type === "value")
|
||||
.map((part) => part.value)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user