mirror of
				https://github.com/home-assistant/frontend.git
				synced 2025-10-31 22:49:37 +00:00 
			
		
		
		
	Compare commits
	
		
			499 Commits
		
	
	
		
			state_colo
			...
			copilot/al
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![copilot-swe-agent[bot]](/assets/img/avatar_default.png)  | 84c8420286 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 1361fc36bf | ||
|   | 505ef2bd11 | ||
|   | c0cc66c1ab | ||
|   | 7cfbc521c7 | ||
|   | e064ce56cc | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 8d688aa3a9 | ||
|   | d122483449 | ||
|   | f17bbc3f79 | ||
|   | c88f8fcce0 | ||
|   | 8efabde916 | ||
|   | e821e1ec83 | ||
|   | dc7516da94 | ||
|   | a545a377a7 | ||
|   | 3634dbcbbf | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 75af4f939e | ||
|   | 453a2ac7f3 | ||
|   | 8fbd0226fc | ||
|   | 2a8d935601 | ||
|   | a6328fb6d7 | ||
|   | a78b61006f | ||
|   | d506aa23b6 | ||
|   | 48b4df43ab | ||
|   | 8cdcd9cb55 | ||
|   | a1e2ac1d99 | ||
|   | 8ecddbc42c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6f70ef52a5 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 7dff02d7c8 | ||
|   | 8bbd7a6a06 | ||
|   | 5c73a06f76 | ||
|   | 9943dae82c | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 70bf049df0 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | f9d9fbb7f0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9cb84d3f37 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c1bcf27cf8 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 164ec2a9b5 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 20001a551c | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | b7f85bf733 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | b303e9441b | ||
|   | 8f4bd0f620 | ||
|   | 596346bf59 | ||
|   | 769cea92aa | ||
|   | f825016514 | ||
|   | c6fd45bd6a | ||
|   | 6c4f4af75c | ||
|   | cd5c3ef2f6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 636a6fa02e | ||
|   | 21b83426d6 | ||
|   | c139ec22f9 | ||
|   | a6ef3a26da | ||
|   | 221ca56121 | ||
|   | 4e6e3629a8 | ||
|   | fe94ae0243 | ||
|   | 8a1a22d4bd | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 153a578986 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 04bb10d0a2 | ||
|   | 35e52de2c1 | ||
|   | b0862fddaa | ||
|   | 77735f5310 | ||
|   | e388756533 | ||
|   | e9ca9bb781 | ||
|   | e48918442c | ||
|   | 52f37f41f0 | ||
|   | 4687006fec | ||
|   | aca4ca3066 | ||
|   | 3a2c00622a | ||
|   | 699c25a6c3 | ||
|   | 1ad226d608 | ||
|   | 992a4cd98a | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | fd217f8ea5 | ||
|   | dede14e578 | ||
|   | fa7aca67e5 | ||
|   | 6abdfa6d5c | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 0a70e2abda | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 1ec589e9b6 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 2d2b5633c4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 76df75c306 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 027ded61c2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a718589ba0 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 5b5dc9d853 | ||
|   | 2a49b5e15a | ||
|   | fa4dd1c5ea | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 37a3af2e8b | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | fbfcef1573 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 4eecd37aaf | ||
|   | c798521ab8 | ||
|   | e432f0a8ee | ||
|   | e3a1d0abe2 | ||
|   | 8080ba696c | ||
|   | 7bd8f321a4 | ||
|   | 4e958302b4 | ||
|   | 8a42d15bde | ||
|   | ef0da0a7ee | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | ae053c20b0 | ||
|   | 5f71938d60 | ||
|   | 82ac26b326 | ||
|   | 80b92b9813 | ||
|   | 904a083f61 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | d75ee09d55 | ||
|   | a8e0d506b6 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 01dd731622 | ||
|   | dc20702d36 | ||
|   | f32ca9be29 | ||
|   | 8c4c4157a8 | ||
|   | c8419d4c3d | ||
|   | 089316b8ae | ||
|   | 8d03ac5f64 | ||
|   | e0e1f6f920 | ||
|   | d4c98cae3a | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 46d0eb4f44 | ||
|   | 07812f8d84 | ||
|   | 96f54d348f | ||
|   | 6084ab116f | ||
|   | 6b7acd8d3b | ||
|   | e35b155c66 | ||
|   | 437d02c12f | ||
|   | 9cd74fbff8 | ||
|   | 33a7aacd83 | ||
|   | 39546615bb | ||
|   | be51cbc944 | ||
|   | 77874aa2d7 | ||
|   | 4808463d5f | ||
|   | 5fb3cab247 | ||
|   | d1093b187f | ||
|   | fd7f0d3841 | ||
|   | 36aa74e4a5 | ||
|   | 938128d1c3 | ||
|   | 2a5d4ac578 | ||
|   | be63ff7702 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 132c68bf20 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 16499bbd6b | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c7eddfed8f | ||
|   | 150842e431 | ||
|   | 9eb5360a68 | ||
|   | e9e32c7d91 | ||
|   | c83d760e82 | ||
|   | 489b7f9227 | ||
|   | ad2ba63155 | ||
|   | 29bc894dbd | ||
|   | faf6cb6333 | ||
|   | a2e1e6362b | ||
|   | 3212ab6f3b | ||
|   | 3d27daad80 | ||
|   | b679f1ce60 | ||
|   | 6b0a5d783b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 23e2f94d11 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c250777858 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c35d0da9bd | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 794aa45a2b | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | d0b85d0c0b | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 23b6a3a1a9 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 43a23e6cdd | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | aa4dd1cf29 | ||
|   | 0ae55c39cc | ||
|   | 0bfacacc9e | ||
|   | c2f21c19af | ||
|   | 6653333c38 | ||
|   | 8c19e080be | ||
|   | c649b1015a | ||
|   | 1b6c33efd4 | ||
|   | 5cfc34b020 | ||
|   | 1e7647b214 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | cef3a7ef99 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 14d0028426 | ||
|   | 28032d9d0d | ||
|   | 6c1995ba1b | ||
|   | b68464c5d5 | ||
|   | 31ccf114a6 | ||
|   | 1b932ae4a2 | ||
|   | 0df6019b95 | ||
|   | 94fb03d2e2 | ||
|   | 6dc165ebf8 | ||
|   | f2c5b91def | ||
|   | b312cca050 | ||
|   | ac14733bff | ||
|   | a2d4165511 | ||
|   | b87ffbd4f7 | ||
|   | a8f8d197f8 | ||
|   | 4fcac79047 | ||
|   | 42ddacd41a | ||
|   | ebc9981289 | ||
|   | 23deab253b | ||
|   | ab172abe02 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 10d5d8b15d | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c9e472dab7 | ||
|   | 1e13b2b812 | ||
|   | e04a04632a | ||
|   | 04bc5fba63 | ||
|   | e66724ca9e | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | bcfe5add33 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7cc116dd07 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ee93f31220 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | b7cc19f12e | ||
|   | f70edf9311 | ||
|   | 0fa7c2face | ||
|   | 7b3a265a70 | ||
|   | 5d9aae3ad5 | ||
|   | 5de84ac0d8 | ||
|   | 98c4ec91d6 | ||
|   | 972b9cb758 | ||
|   | ac621af811 | ||
|   | 7eb97bb58f | ||
|   | d37af0f488 | ||
|   | 0d3b340228 | ||
|   | 288e03775b | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | df36e9d205 | ||
|   | 15a0b35866 | ||
|   | aa7522f681 | ||
|   | c09e97a561 | ||
|   | 733be8e5a3 | ||
|   | d107ac7d4c | ||
|   | efc5bacb97 | ||
|   | 430e52efe3 | ||
|   | 6b4c4a9cf8 | ||
|   | e5b1acc2c3 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c89f476d67 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | e68afead17 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c4651c0bc0 | ||
|   | 6d95b7af11 | ||
|   | 3e74cf3ada | ||
|   | 859ee98abb | ||
|   | dd3e5e3724 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 2e3ab4d64f | ||
|   | 63cbeca820 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 1057ff314c | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 5b946f1048 | ||
|   | fdd66b5cec | ||
|   | 76c9723c71 | ||
|   | b02368b9c6 | ||
|   | 0bcb7897c9 | ||
|   | 786bbb3850 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | e8ead84fe5 | ||
|   | 428e7fb332 | ||
|   | ad9e8d5a52 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | e3cf04b3d1 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 10c3042db1 | ||
|   | 25f6b7de2f | ||
|   | ca1cda4824 | ||
|   | 8c4a67315b | ||
|   | c18de97b32 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 23a3ca3ed7 | ||
|   | 69457b4e85 | ||
|   | 2e096c23e0 | ||
|   | 552691e200 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 91258c86c1 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 3750a378cd | ||
|   | 12d3304c72 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 246100809d | ||
|   | 6efca93186 | ||
|   | 6280647b9a | ||
|   | 2ff52c6c29 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | d038e11170 | ||
|   | 8925b39fe5 | ||
|   | beeef65506 | ||
|   | 994c1b5751 | ||
|   | 6823c647b6 | ||
|   | 866b478dc0 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | d746dc5752 | ||
|   | 5f53e1e71c | ||
|   | 3da82df093 | ||
|   | 4cedfffb71 | ||
|   | 1e1514e7da | ||
|   | 60e07075bc | ||
|   | c998086474 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 53be0a3fa2 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | d69c46c80c | ||
|   | 0c2a7bfed0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | afdd232e38 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 179751a135 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 52f6024306 | ||
|   | 7c7a4e61f2 | ||
|   | facce7b016 | ||
|   | e546cb3374 | ||
|   | a0d2e7312b | ||
|   | c0a9dadcbe | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | e1edf7fb98 | ||
|   | 6d5c165bd2 | ||
|   | 54177a16e9 | ||
|   | c814b8e888 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 33a0b32cc5 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 7dae13bf57 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 0a3fe6e0fb | ||
|   | e0348e4da7 | ||
|   | d53f3ec898 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | e422547d93 | ||
|   | d91a3fbe85 | ||
|   | 01d7130f22 | ||
|   | c57851e4df | ||
|   | 6f1f13acb0 | ||
|   | a8abd00809 | ||
|   | e053978dbe | ||
|   | 6e57f726a3 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | b7cabadbe1 | ||
|   | d920217374 | ||
|   | 1630263276 | ||
|   | 5680c742be | ||
|   | 2aeb9cf0ef | ||
|   | c9931b3a3c | ||
|   | fbf7ebdfe4 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 52ccb03de5 | ||
|   | 900236ac07 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 28940c930d | ||
|   | e278b463fd | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | db2acd4e39 | ||
|   | 6dcc52cd44 | ||
|   | 981db50826 | ||
|   | 09683863a7 | ||
|   | 8c78f931dc | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 40ce3c1e31 | ||
|   | e430a1b1be | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | a2c6116417 | ||
|   | 3239273f3e | ||
|   | e42c5a3254 | ||
|   | df7a6297b0 | ||
|   | e4ca478d01 | ||
|   | 7be2c59295 | ||
|   | 99d9c67492 | ||
|   | 8f781e53e3 | ||
|   | 3c92826e71 | ||
|   | 151a879e0a | ||
|   | f3a8529ed7 | ||
|   | d2cc7856d1 | ||
|   | d5cb815bbd | ||
|   | 7f88d863e9 | ||
|   | 88ac56ac0b | ||
|   | 3d173ad03e | ||
|   | 3889d71768 | ||
|   | 8872adf2ed | ||
|   | 969e655fff | ||
|   | cdc913d878 | ||
|   | 4ac1215def | ||
|   | b2376fba56 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | f14d9198ac | ||
|   | f4e583b302 | ||
|   | 2c602aecee | ||
|   | cbf96898fe | ||
|   | 6760f4a2ae | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 3481f7e8be | ||
|   | 95a0fe335f | ||
|   | 1e2d144d26 | ||
|   | 6aa89cb532 | ||
|   | 1b0ed7017f | ||
|   | 1cc7e387da | ||
|   | 41bf935f6e | ||
|   | b08ea36a1e | ||
|   | 4f52a46725 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | f8a82563b0 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | a1672ccdfb | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | bde851e5a4 | ||
|   | a6d3041d59 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | f64edfa305 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 067b321d84 | ||
|   | 33efe395c8 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | db26b1041f | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 6e9b4637bb | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 0e30e5e0f4 | ||
|   | 283da74e2d | ||
|   | 034afd1375 | ||
|   | 912d710ae4 | ||
|   | 86b99d931a | ||
|   | 35cfa9aa0d | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 6a23dbf204 | ||
|   | cef8fc1d38 | ||
|   | 7c06e33b50 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | cb365d4635 | ||
|   | 525102678b | ||
|   | dfc4b0bba2 | ||
|   | 846692bc8a | ||
|   | 3b90b5fcb1 | ||
|   | cac978344f | ||
|   | 6a40631e6d | ||
|   | 48f5b6dfd3 | ||
|   | 04b01d2cd9 | ||
|   | 0e8e054db1 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 477a893193 | ||
|   | bd0822f09f | ||
|   | 07c3ffb55d | ||
|   | fbfb4709d2 | ||
|   | 0a5b31e328 | ||
|   | 8cf0d8d2c3 | ||
|   | 61c16ce020 | ||
|   | 6bede4ddca | ||
|   | bd88b91071 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 29b02a3c99 | ||
|   | ac87e2280d | ||
|   | 98c4e34a23 | ||
|   | 3d005c8316 | ||
|   | af31b5add3 | ||
|   | 9d02a1d391 | ||
|   | 98e6f32fe8 | ||
|   | 2726c6a849 | ||
|   | c09ec54c76 | ||
|   | 9f045538a2 | ||
|   | c6c4f91b0e | ||
|   | f71d8f4367 | ||
|   | 68c1a38231 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a9796e4216 | ||
|   | bf6eefb692 | ||
|   | 7ec3b08444 | ||
|   | f3355671d1 | ||
|   | c0e240a3bf | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 00fd4753e4 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 08ac873e3b | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | d12b8d1b1b | ||
|   | 977207dde4 | ||
|   | 87a5f1a315 | ||
|   | d64acca598 | ||
|   | 59571d03a6 | ||
|   | 28c515bbac | ||
|   | 27db5b3b02 | ||
|   | 1922db0474 | ||
|   | c8c74a9744 | ||
|   | 2c676baa99 | ||
|   | 3e41474faa | ||
|   | 5f9c69ac21 | ||
|   | 8b45ccaaba | ||
|   | 455925f637 | ||
|   | 9fba7427f8 | ||
|   | 21aae02652 | ||
|   | 24e3fbf622 | ||
|   | 2cbcf1a689 | ||
|   | 1c1c0d70c5 | ||
|   | a66f5fb573 | ||
|   | 9affeab755 | ||
|   | 2bfaf77908 | ||
|   | bc4caae796 | ||
|   | 8746acd329 | ||
|   | 96ecf16da2 | ||
|   | 1e95a0f3ef | ||
|   | a164d793b1 | ||
|   | 510fc71b40 | ||
|   | 2a6a3edb77 | ||
|   | c7a8796a47 | ||
|   | 9d40fa5f2b | ||
|   | 8f2a023775 | ||
|   | 989b0b34fe | ||
|   | cf94e71215 | ||
|   | 49896f3fa6 | ||
|   | fc4b7674b1 | ||
|   | 04c9f32539 | ||
|   | 21e3fc9bb9 | ||
|   | 4b78eb7656 | ||
|   | e6f91aef8e | ||
|   | 8f99f86c8b | ||
|   | b7eff547c7 | ||
|   | ceb6b64152 | ||
|   | d253041376 | ||
|   | cb0aa81f89 | ||
|   | 42061b2f8c | ||
|   | 69bfb89a65 | ||
|   | e0307f9688 | ||
|   | 1cf353461f | ||
|   | 1786235c86 | ||
|   | 645ba3f9c1 | ||
|   | b65f6f46e1 | ||
|   | 84ad521b3d | ||
|   | dfb9c662e7 | ||
|   | 5ac42e17b0 | ||
|   | be2f19637e | ||
|   | b7a6ee3792 | ||
|   | 1fb2f0c989 | ||
|   | b4ad411e6f | ||
|   | 5d76a92f73 | ||
|   | beee09491a | ||
|   | ee5aabdddf | ||
|   | ec80f6a6f1 | ||
|   | 9845f0b47c | ||
|   | cd294ba619 | ||
|   | 61e27cb1ea | ||
|   | 8d6295e8e8 | ||
|   | b0e95699f7 | ||
|   | c8e1e7b8a8 | ||
|   | d2cea159af | ||
|   | eb5d1c79c8 | ||
|   | 65ab6848ab | ||
|   | 7a1d934e8d | ||
|   | cbacde12fa | ||
|   | 4c33618e05 | ||
|   | 3837b3e630 | ||
|   | 7c15633f6d | ||
|   | f7ec8650eb | ||
|   | 7674eee0fb | ||
|   | f494a6453a | ||
|   | 37f3682ffa | ||
|   | 8055286a1f | ||
|   | 0bdd213761 | ||
|   | 810b43760e | ||
|   | 424d71c55a | ||
|   | 176924241c | ||
|   | da08aa7fb0 | ||
|   | 6047227648 | ||
|   | fc71fd6bc3 | ||
|   | 90a1b135e1 | ||
|   | e19413b6ca | ||
|   | 0dfc10af5f | ||
|   | bbbc419bea | ||
|   | 50ad5e376f | ||
|   | a9f2254bbc | ||
|   | a8836404d4 | 
							
								
								
									
										12
									
								
								.github/workflows/cast_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/cast_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -21,12 +21,12 @@ jobs: | ||||
|       url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|         with: | ||||
|           ref: dev | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -42,7 +42,7 @@ jobs: | ||||
|       - name: Deploy to Netlify | ||||
|         id: deploy | ||||
|         run: | | ||||
|           npx -y netlify-cli deploy --dir=cast/dist --alias dev | ||||
|           npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev | ||||
|         env: | ||||
|           NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | ||||
|           NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }} | ||||
| @@ -56,12 +56,12 @@ jobs: | ||||
|       url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|         with: | ||||
|           ref: master | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -77,7 +77,7 @@ jobs: | ||||
|       - name: Deploy to Netlify | ||||
|         id: deploy | ||||
|         run: | | ||||
|           npx -y netlify-cli deploy --dir=cast/dist --prod | ||||
|           npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod | ||||
|         env: | ||||
|           NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | ||||
|           NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }} | ||||
|   | ||||
							
								
								
									
										22
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -24,9 +24,9 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -37,7 +37,7 @@ jobs: | ||||
|       - name: Build resources | ||||
|         run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages | ||||
|       - name: Setup lint cache | ||||
|         uses: actions/cache@v4.2.4 | ||||
|         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | ||||
|         with: | ||||
|           path: | | ||||
|             node_modules/.cache/prettier | ||||
| @@ -58,9 +58,9 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -76,9 +76,9 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -89,7 +89,7 @@ jobs: | ||||
|         env: | ||||
|           IS_TEST: "true" | ||||
|       - name: Upload bundle stats | ||||
|         uses: actions/upload-artifact@v4.6.2 | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         with: | ||||
|           name: frontend-bundle-stats | ||||
|           path: build/stats/*.json | ||||
| @@ -100,9 +100,9 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -113,7 +113,7 @@ jobs: | ||||
|         env: | ||||
|           IS_TEST: "true" | ||||
|       - name: Upload bundle stats | ||||
|         uses: actions/upload-artifact@v4.6.2 | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         with: | ||||
|           name: supervisor-bundle-stats | ||||
|           path: build/stats/*.json | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -23,7 +23,7 @@ jobs: | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|         with: | ||||
|           # We must fetch at least the immediate parents so that if this is | ||||
|           # a pull request then we can checkout the head. | ||||
| @@ -36,14 +36,14 @@ jobs: | ||||
|  | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v3 | ||||
|         uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|         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@v3 | ||||
|         uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|  | ||||
|       # ℹ️ Command-line programs to run using the OS shell. | ||||
|       # 📚 https://git.io/JvXDl | ||||
| @@ -57,4 +57,4 @@ jobs: | ||||
|       #   make release | ||||
|  | ||||
|       - name: Perform CodeQL Analysis | ||||
|         uses: github/codeql-action/analyze@v3 | ||||
|         uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|   | ||||
							
								
								
									
										12
									
								
								.github/workflows/demo_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/demo_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -22,12 +22,12 @@ jobs: | ||||
|       url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|         with: | ||||
|           ref: dev | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -43,7 +43,7 @@ jobs: | ||||
|       - name: Deploy to Netlify | ||||
|         id: deploy | ||||
|         run: | | ||||
|           npx -y netlify-cli deploy --dir=demo/dist --prod | ||||
|           npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod | ||||
|         env: | ||||
|           NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | ||||
|           NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }} | ||||
| @@ -57,12 +57,12 @@ jobs: | ||||
|       url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|         with: | ||||
|           ref: master | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -78,7 +78,7 @@ jobs: | ||||
|       - name: Deploy to Netlify | ||||
|         id: deploy | ||||
|         run: | | ||||
|           npx -y netlify-cli deploy --dir=demo/dist --prod | ||||
|           npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod | ||||
|         env: | ||||
|           NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | ||||
|           NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }} | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/design_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/design_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -16,10 +16,10 @@ jobs: | ||||
|       url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -35,7 +35,7 @@ jobs: | ||||
|       - name: Deploy to Netlify | ||||
|         id: deploy | ||||
|         run: | | ||||
|           npx -y netlify-cli deploy --dir=gallery/dist --prod | ||||
|           npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod | ||||
|         env: | ||||
|           NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | ||||
|           NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }} | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/design_preview.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/design_preview.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -21,10 +21,10 @@ jobs: | ||||
|     if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') | ||||
|     steps: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -40,7 +40,7 @@ jobs: | ||||
|       - name: Deploy preview to Netlify | ||||
|         id: deploy | ||||
|         run: | | ||||
|           npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \ | ||||
|           npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \ | ||||
|             --json > deploy_output.json | ||||
|         env: | ||||
|           NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/labeler.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/labeler.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -10,6 +10,6 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Apply labels | ||||
|         uses: actions/labeler@v6.0.1 | ||||
|         uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 | ||||
|         with: | ||||
|           sync-labels: true | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/lock.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/lock.yml
									
									
									
									
										vendored
									
									
								
							| @@ -9,7 +9,7 @@ jobs: | ||||
|   lock: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: dessant/lock-threads@v5.0.1 | ||||
|       - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 | ||||
|         with: | ||||
|           github-token: ${{ github.token }} | ||||
|           process-only: "issues, prs" | ||||
|   | ||||
							
								
								
									
										10
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -20,15 +20,15 @@ jobs: | ||||
|       contents: write | ||||
|     steps: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Set up Python ${{ env.PYTHON_VERSION }} | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6 | ||||
|         with: | ||||
|           python-version: ${{ env.PYTHON_VERSION }} | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -57,14 +57,14 @@ jobs: | ||||
|         run: tar -czvf translations.tar.gz translations | ||||
|  | ||||
|       - name: Upload build artifacts | ||||
|         uses: actions/upload-artifact@v4.6.2 | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         with: | ||||
|           name: wheels | ||||
|           path: dist/home_assistant_frontend*.whl | ||||
|           if-no-files-found: error | ||||
|  | ||||
|       - name: Upload translations | ||||
|         uses: actions/upload-artifact@v4.6.2 | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         with: | ||||
|           name: translations | ||||
|           path: translations.tar.gz | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/relative-ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/relative-ci.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Send bundle stats and build information to RelativeCI | ||||
|         uses: relative-ci/agent-action@v3.0.1 | ||||
|         uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0 | ||||
|         with: | ||||
|           key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} | ||||
|           token: ${{ github.token }} | ||||
|   | ||||
							
								
								
									
										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@v6.1.0 | ||||
|       - uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0 | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|   | ||||
							
								
								
									
										23
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -23,10 +23,10 @@ jobs: | ||||
|       contents: write # Required to upload release assets | ||||
|     steps: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Set up Python ${{ env.PYTHON_VERSION }} | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 | ||||
|         with: | ||||
|           python-version: ${{ env.PYTHON_VERSION }} | ||||
|  | ||||
| @@ -34,7 +34,7 @@ jobs: | ||||
|         uses: home-assistant/actions/helpers/verify-version@master | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -55,7 +55,7 @@ jobs: | ||||
|           script/release | ||||
|  | ||||
|       - name: Upload release assets | ||||
|         uses: softprops/action-gh-release@v2.3.3 | ||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||
|         with: | ||||
|           files: | | ||||
|             dist/*.whl | ||||
| @@ -73,8 +73,9 @@ jobs: | ||||
|           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.07.0 | ||||
|         uses: home-assistant/wheels@2025.10.0 | ||||
|         with: | ||||
|           abi: cp313 | ||||
|           tag: musllinux_1_2 | ||||
| @@ -90,9 +91,9 @@ jobs: | ||||
|       contents: write # Required to upload release assets | ||||
|     steps: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -107,7 +108,7 @@ jobs: | ||||
|       - name: Tar folder | ||||
|         run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist . | ||||
|       - name: Upload release asset | ||||
|         uses: softprops/action-gh-release@v2.3.3 | ||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||
|         with: | ||||
|           files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz | ||||
|  | ||||
| @@ -119,9 +120,9 @@ jobs: | ||||
|       contents: write # Required to upload release assets | ||||
|     steps: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -136,6 +137,6 @@ jobs: | ||||
|       - name: Tar folder | ||||
|         run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build . | ||||
|       - name: Upload release asset | ||||
|         uses: softprops/action-gh-release@v2.3.3 | ||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||
|         with: | ||||
|           files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/restrict-task-creation.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/restrict-task-creation.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ jobs: | ||||
|     if: github.event.issue.type.name == 'Task' | ||||
|     steps: | ||||
|       - name: Check if user is authorized | ||||
|         uses: actions/github-script@v8 | ||||
|         uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const issueAuthor = context.payload.issue.user.login; | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							| @@ -10,7 +10,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: 90 days stale policy | ||||
|         uses: actions/stale@v10.0.0 | ||||
|         uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 | ||||
|         with: | ||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           days-before-stale: 90 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/translations.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/translations.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -14,7 +14,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@v5.0.0 | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Upload Translations | ||||
|         run: | | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -6,4 +6,4 @@ enableGlobalCache: false | ||||
|  | ||||
| nodeLinker: node-modules | ||||
|  | ||||
| yarnPath: .yarn/releases/yarn-4.9.4.cjs | ||||
| yarnPath: .yarn/releases/yarn-4.10.3.cjs | ||||
|   | ||||
| @@ -183,7 +183,6 @@ module.exports.babelOptions = ({ | ||||
|       include: /\/node_modules\//, | ||||
|       exclude: [ | ||||
|         "element-internals-polyfill", | ||||
|         "@shoelace-style", | ||||
|         "@?lit(?:-labs|-element|-html)?", | ||||
|       ].map((p) => new RegExp(`/node_modules/${p}/`)), | ||||
|     }, | ||||
|   | ||||
| @@ -242,7 +242,7 @@ class HcCast extends LitElement { | ||||
|     } | ||||
|  | ||||
|     .question:before { | ||||
|       border-radius: 4px; | ||||
|       border-radius: var(--ha-border-radius-sm); | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       right: 0; | ||||
|   | ||||
| @@ -95,7 +95,8 @@ class HcLayout extends LitElement { | ||||
|     } | ||||
|  | ||||
|     .hero { | ||||
|       border-radius: 4px 4px 0 0; | ||||
|       border-radius: var(--ha-border-radius-sm) var(--ha-border-radius-sm) | ||||
|         var(--ha-border-radius-square) var(--ha-border-radius-square); | ||||
|     } | ||||
|     .subtitle { | ||||
|       font-size: var(--ha-font-size-m); | ||||
|   | ||||
| @@ -5,17 +5,17 @@ const castContext = framework.CastReceiverContext.getInstance(); | ||||
| const playerManager = castContext.getPlayerManager(); | ||||
|  | ||||
| playerManager.setMessageInterceptor( | ||||
|   "LOAD" as framework.messages.MessageType.LOAD, | ||||
|   framework.messages.MessageType.LOAD, | ||||
|   (loadRequestData) => { | ||||
|     const media = loadRequestData.media; | ||||
|     // Special handling if it came from Google Assistant | ||||
|     if (media.entity) { | ||||
|       media.contentId = media.entity; | ||||
|       media.streamType = "LIVE" as framework.messages.StreamType.LIVE; | ||||
|       media.streamType = framework.messages.StreamType.LIVE; | ||||
|       media.contentType = "application/vnd.apple.mpegurl"; | ||||
|       // @ts-ignore | ||||
|       media.hlsVideoSegmentFormat = | ||||
|         "fmp4" as framework.messages.HlsVideoSegmentFormat.FMP4; | ||||
|         framework.messages.HlsVideoSegmentFormat.FMP4; | ||||
|     } | ||||
|     return loadRequestData; | ||||
|   } | ||||
|   | ||||
| @@ -75,7 +75,7 @@ export const castDemoEntities: () => Entity[] = () => | ||||
|         longitude: 4.8903147, | ||||
|         radius: 100, | ||||
|         friendly_name: "Home", | ||||
|         icon: "hass:home", | ||||
|         icon: "mdi:home", | ||||
|       }, | ||||
|     }, | ||||
|     "input_number.harmonyvolume": { | ||||
| @@ -88,7 +88,7 @@ export const castDemoEntities: () => Entity[] = () => | ||||
|         step: 1, | ||||
|         mode: "slider", | ||||
|         friendly_name: "Volume", | ||||
|         icon: "hass:volume-high", | ||||
|         icon: "mdi:volume-high", | ||||
|       }, | ||||
|     }, | ||||
|     "climate.upstairs": { | ||||
|   | ||||
| @@ -56,7 +56,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => { | ||||
|                 type: "weblink", | ||||
|                 url: "/lovelace/climate", | ||||
|                 name: "Climate controls", | ||||
|                 icon: "hass:arrow-right", | ||||
|                 icon: "mdi:arrow-right", | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
| @@ -76,7 +76,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => { | ||||
|                 type: "weblink", | ||||
|                 url: "/lovelace/overview", | ||||
|                 name: "Back", | ||||
|                 icon: "hass:arrow-left", | ||||
|                 icon: "mdi:arrow-left", | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|   | ||||
| @@ -40,8 +40,7 @@ const playDummyMedia = (viewTitle?: string) => { | ||||
|   loadRequestData.media.contentId = | ||||
|     "https://cast.home-assistant.io/images/google-nest-hub.png"; | ||||
|   loadRequestData.media.contentType = "image/jpeg"; | ||||
|   loadRequestData.media.streamType = | ||||
|     "NONE" as framework.messages.StreamType.NONE; | ||||
|   loadRequestData.media.streamType = framework.messages.StreamType.NONE; | ||||
|   const metadata = new framework.messages.GenericMediaMetadata(); | ||||
|   metadata.title = viewTitle; | ||||
|   loadRequestData.media.metadata = metadata; | ||||
| @@ -90,7 +89,7 @@ const showMediaPlayer = () => { | ||||
| const options = new framework.CastReceiverOptions(); | ||||
| options.disableIdleTimeout = true; | ||||
| options.customNamespaces = { | ||||
|   [CAST_NS]: "json" as framework.system.MessageType.JSON, | ||||
|   [CAST_NS]: framework.system.MessageType.JSON, | ||||
| }; | ||||
|  | ||||
| castContext.addCustomMessageListener( | ||||
| @@ -98,7 +97,9 @@ castContext.addCustomMessageListener( | ||||
|   // @ts-ignore | ||||
|   (ev: ReceivedMessage<HassMessage>) => { | ||||
|     // We received a show Lovelace command, stop media from playing, hide media player and show Lovelace controller | ||||
|     if (playerManager.getPlayerState() !== "IDLE") { | ||||
|     if ( | ||||
|       playerManager.getPlayerState() !== framework.messages.PlayerState.IDLE | ||||
|     ) { | ||||
|       playerManager.stop(); | ||||
|     } else { | ||||
|       showLovelaceController(); | ||||
| @@ -112,7 +113,7 @@ castContext.addCustomMessageListener( | ||||
| const playerManager = castContext.getPlayerManager(); | ||||
|  | ||||
| playerManager.setMessageInterceptor( | ||||
|   "LOAD" as framework.messages.MessageType.LOAD, | ||||
|   framework.messages.MessageType.LOAD, | ||||
|   (loadRequestData) => { | ||||
|     if ( | ||||
|       loadRequestData.media.contentId === | ||||
| @@ -126,23 +127,24 @@ playerManager.setMessageInterceptor( | ||||
|     // Special handling if it came from Google Assistant | ||||
|     if (media.entity) { | ||||
|       media.contentId = media.entity; | ||||
|       media.streamType = "LIVE" as framework.messages.StreamType.LIVE; | ||||
|       media.streamType = framework.messages.StreamType.LIVE; | ||||
|       media.contentType = "application/vnd.apple.mpegurl"; | ||||
|       // @ts-ignore | ||||
|       media.hlsVideoSegmentFormat = | ||||
|         "fmp4" as framework.messages.HlsVideoSegmentFormat.FMP4; | ||||
|         framework.messages.HlsVideoSegmentFormat.FMP4; | ||||
|     } | ||||
|     return loadRequestData; | ||||
|   } | ||||
| ); | ||||
|  | ||||
| playerManager.addEventListener( | ||||
|   "MEDIA_STATUS" as framework.events.EventType.MEDIA_STATUS, | ||||
|   framework.events.EventType.MEDIA_STATUS, | ||||
|   (event) => { | ||||
|     if ( | ||||
|       event.mediaStatus?.playerState === "IDLE" && | ||||
|       event.mediaStatus?.playerState === framework.messages.PlayerState.IDLE && | ||||
|       event.mediaStatus?.idleReason && | ||||
|       event.mediaStatus?.idleReason !== "INTERRUPTED" | ||||
|       event.mediaStatus?.idleReason !== | ||||
|         framework.messages.IdleReason.INTERRUPTED | ||||
|     ) { | ||||
|       // media finished or stopped, return to default Lovelace | ||||
|       showLovelaceController(); | ||||
|   | ||||
| @@ -143,7 +143,7 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) => | ||||
|       state: "on", | ||||
|       attributes: { | ||||
|         friendly_name: "Home Automation", | ||||
|         icon: "hass:home-automation", | ||||
|         icon: "mdi:home-automation", | ||||
|       }, | ||||
|     }, | ||||
|     "input_boolean.tvtime": { | ||||
|   | ||||
| @@ -4,7 +4,7 @@ export const demoLovelaceArsaboo: DemoConfig["lovelace"] = (localize) => ({ | ||||
|   title: "Home Assistant", | ||||
|   views: [ | ||||
|     { | ||||
|       icon: "hass:home-assistant", | ||||
|       icon: "mdi:home-assistant", | ||||
|       id: "home", | ||||
|       title: "Home", | ||||
|       cards: [ | ||||
|   | ||||
| @@ -1236,7 +1236,7 @@ export const demoLovelaceJimpower: DemoConfig["lovelace"] = () => ({ | ||||
|         }, | ||||
|       ], | ||||
|       path: "security", | ||||
|       icon: "hass:shield-home", | ||||
|       icon: "mdi:shield-home", | ||||
|       name: "Security", | ||||
|       background: | ||||
|         'center / cover no-repeat url("/assets/jimpower/background-15.jpg") fixed', | ||||
|   | ||||
| @@ -17,6 +17,10 @@ export const createMediaPlayerEntities = () => [ | ||||
|       new Date().getTime() - 23000 | ||||
|     ).toISOString(), | ||||
|     volume_level: 0.5, | ||||
|     source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"], | ||||
|     source: "AirPlay", | ||||
|     sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"], | ||||
|     sound_mode: "Music", | ||||
|   }), | ||||
|   getEntity("media_player", "music_playing", "playing", { | ||||
|     friendly_name: "Playing The Music", | ||||
| @@ -24,8 +28,8 @@ export const createMediaPlayerEntities = () => [ | ||||
|     media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)", | ||||
|     media_artist: "Technohead", | ||||
|     // Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + Play Media + | ||||
|     // Select Source + Stop + Clear + Play + Shuffle Set + Browse Media | ||||
|     supported_features: 195135, | ||||
|     // Select Source + Stop + Clear + Play + Shuffle Set + Browse Media + Grouping | ||||
|     supported_features: 784959, | ||||
|     entity_picture: "/images/album_cover.jpg", | ||||
|     media_duration: 300, | ||||
|     media_position: 0, | ||||
| @@ -34,6 +38,9 @@ export const createMediaPlayerEntities = () => [ | ||||
|       new Date().getTime() - 23000 | ||||
|     ).toISOString(), | ||||
|     volume_level: 0.5, | ||||
|     sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"], | ||||
|     sound_mode: "Music", | ||||
|     group_members: ["media_player.playing", "media_player.stream_playing"], | ||||
|   }), | ||||
|   getEntity("media_player", "stream_playing", "playing", { | ||||
|     friendly_name: "Playing the Stream", | ||||
| @@ -149,15 +156,18 @@ export const createMediaPlayerEntities = () => [ | ||||
|   }), | ||||
|   getEntity("media_player", "receiver_on", "on", { | ||||
|     source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"], | ||||
|     sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"], | ||||
|     volume_level: 0.63, | ||||
|     is_volume_muted: false, | ||||
|     source: "TV", | ||||
|     sound_mode: "Movie", | ||||
|     friendly_name: "Receiver (selectable sources)", | ||||
|     // Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode | ||||
|     supported_features: 84364, | ||||
|   }), | ||||
|   getEntity("media_player", "receiver_off", "off", { | ||||
|     source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"], | ||||
|     sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"], | ||||
|     friendly_name: "Receiver (selectable sources)", | ||||
|     // Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode | ||||
|     supported_features: 84364, | ||||
|   | ||||
| @@ -208,7 +208,7 @@ class HaGallery extends LitElement { | ||||
|       } | ||||
|  | ||||
|       .sidebar a[active]::before { | ||||
|         border-radius: 12px; | ||||
|         border-radius: var(--ha-border-radius-lg); | ||||
|         position: absolute; | ||||
|         top: 0; | ||||
|         right: 2px; | ||||
| @@ -241,7 +241,7 @@ class HaGallery extends LitElement { | ||||
|         text-align: center; | ||||
|         margin: 16px; | ||||
|         padding: 16px; | ||||
|         border-radius: 12px; | ||||
|         border-radius: var(--ha-border-radius-lg); | ||||
|         background-color: var(--primary-background-color); | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -117,7 +117,7 @@ export class DemoHaBadge extends LitElement { | ||||
|     } | ||||
|     .card-content { | ||||
|       display: flex; | ||||
|       gap: 24px; | ||||
|       gap: var(--ha-space-6); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|   | ||||
| @@ -155,11 +155,11 @@ export class DemoHaButton extends LitElement { | ||||
|     .card-content { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 24px; | ||||
|       gap: var(--ha-space-6); | ||||
|     } | ||||
|     .card-content div { | ||||
|       display: flex; | ||||
|       gap: 8px; | ||||
|       gap: var(--ha-space-2); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|   | ||||
| @@ -9,10 +9,10 @@ import { css, html, LitElement } from "lit"; | ||||
| import { customElement } from "lit/decorators"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import "../../../../src/components/ha-control-button"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-svg-icon"; | ||||
| import "../../../../src/components/ha-control-button"; | ||||
| import "../../../../src/components/ha-control-button-group"; | ||||
| import "../../../../src/components/ha-svg-icon"; | ||||
|  | ||||
| interface Button { | ||||
|   label: string; | ||||
| @@ -156,17 +156,17 @@ export class DemoHaBarButton extends LitElement { | ||||
|       --control-button-icon-color: var(--primary-color); | ||||
|       --control-button-background-color: var(--primary-color); | ||||
|       --control-button-background-opacity: 0.2; | ||||
|       --control-button-border-radius: 18px; | ||||
|       --control-button-border-radius: var(--ha-border-radius-xl); | ||||
|       height: 100px; | ||||
|       width: 100px; | ||||
|     } | ||||
|     .custom-group { | ||||
|       --control-button-group-thickness: 100px; | ||||
|       --control-button-group-border-radius: 36px; | ||||
|       --control-button-group-border-radius: var(--ha-border-radius-6xl); | ||||
|       --control-button-group-spacing: 20px; | ||||
|     } | ||||
|     .custom-group ha-control-button { | ||||
|       --control-button-border-radius: 18px; | ||||
|       --control-button-border-radius: var(--ha-border-radius-xl); | ||||
|       --mdc-icon-size: 32px; | ||||
|     } | ||||
|     .vertical-buttons { | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import type { TemplateResult } from "lit"; | ||||
| import { LitElement, css, html } from "lit"; | ||||
| import { customElement, state } from "lit/decorators"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-control-number-buttons"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
|  | ||||
| const buttons: { | ||||
|   id: string; | ||||
| @@ -94,7 +94,7 @@ export class DemoHarControlNumberButtons extends LitElement { | ||||
|       --control-number-buttons-background-color: #2196f3; | ||||
|       --control-number-buttons-background-opacity: 0.1; | ||||
|       --control-number-buttons-thickness: 100px; | ||||
|       --control-number-buttons-border-radius: 36px; | ||||
|       --control-number-buttons-border-radius: var(--ha-border-radius-6xl); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|   | ||||
| @@ -131,7 +131,7 @@ export class DemoHaControlSelectMenu extends LitElement { | ||||
|       --control-button-icon-color: var(--primary-color); | ||||
|       --control-button-background-color: var(--primary-color); | ||||
|       --control-button-background-opacity: 0.2; | ||||
|       --control-button-border-radius: 18px; | ||||
|       --control-button-border-radius: var(--ha-border-radius-xl); | ||||
|       height: 100px; | ||||
|       width: 100px; | ||||
|     } | ||||
|   | ||||
| @@ -187,7 +187,7 @@ export class DemoHaControlSelect extends LitElement { | ||||
|       --mdc-icon-size: 24px; | ||||
|       --control-select-color: var(--state-fan-active-color); | ||||
|       --control-select-thickness: 130px; | ||||
|       --control-select-border-radius: 36px; | ||||
|       --control-select-border-radius: var(--ha-border-radius-6xl); | ||||
|     } | ||||
|     .vertical-selects { | ||||
|       height: 300px; | ||||
|   | ||||
| @@ -3,8 +3,8 @@ import { css, html, LitElement } from "lit"; | ||||
| import { customElement, state } from "lit/decorators"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import "../../../../src/components/ha-control-slider"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-control-slider"; | ||||
|  | ||||
| const sliders: { | ||||
|   id: string; | ||||
| @@ -151,7 +151,7 @@ export class DemoHaBarSlider extends LitElement { | ||||
|       --control-slider-background: #ffcf4c; | ||||
|       --control-slider-background-opacity: 0.2; | ||||
|       --control-slider-thickness: 130px; | ||||
|       --control-slider-border-radius: 36px; | ||||
|       --control-slider-border-radius: var(--ha-border-radius-6xl); | ||||
|     } | ||||
|     .vertical-sliders { | ||||
|       height: 300px; | ||||
|   | ||||
| @@ -9,8 +9,8 @@ import { css, html, LitElement } from "lit"; | ||||
| import { customElement, state } from "lit/decorators"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import "../../../../src/components/ha-control-switch"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-control-switch"; | ||||
|  | ||||
| const switches: { | ||||
|   id: string; | ||||
| @@ -118,7 +118,7 @@ export class DemoHaControlSwitch extends LitElement { | ||||
|       --control-switch-on-color: var(--green-color); | ||||
|       --control-switch-off-color: var(--red-color); | ||||
|       --control-switch-thickness: 130px; | ||||
|       --control-switch-border-radius: 36px; | ||||
|       --control-switch-border-radius: var(--ha-border-radius-6xl); | ||||
|       --control-switch-padding: 6px; | ||||
|       --mdc-icon-size: 24px; | ||||
|     } | ||||
|   | ||||
| @@ -5,14 +5,14 @@ subtitle: Dialogs provide important prompts in a user flow. | ||||
|  | ||||
| # Material Design 3 | ||||
|  | ||||
| Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guideliness. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview). | ||||
| Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guidelines. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview). | ||||
|  | ||||
| # Guidelines | ||||
|  | ||||
| ## Design | ||||
|  | ||||
| - Dialogs have a max width of 560px. Alert and confirmation dialogs got a fixed width of 320px. If you need more width, consider a dedicated page instead. | ||||
| - The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guideliness. | ||||
| - Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead. | ||||
| - The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines. | ||||
| - Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake. | ||||
| - Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu. | ||||
| - The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom. | ||||
| @@ -26,7 +26,7 @@ Our dialogs are based on the latest version of Material Design. Please note that | ||||
|  | ||||
| - A best practice is to always use a title, even if it is optional by Material guidelines. | ||||
| - People mainly read the title and a button. Put the most important information in those two. | ||||
| - Try to avoid user generated content in the title, this could make the title unreadable long. | ||||
| - Try to avoid user generated content in the title, this could make the title unreadably long. | ||||
| - If users become unsure, they read the description. Make sure this explains what will happen. | ||||
| - Strive for minimalism. | ||||
|  | ||||
|   | ||||
							
								
								
									
										37
									
								
								gallery/src/pages/components/ha-marquee-text.markdown
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								gallery/src/pages/components/ha-marquee-text.markdown
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| --- | ||||
| title: Marquee Text | ||||
| --- | ||||
|  | ||||
| # Marquee Text `<ha-marquee-text>` | ||||
|  | ||||
| Marquee text component scrolls text horizontally if it overflows its container. It supports pausing on hover and customizable speed and pause duration. | ||||
|  | ||||
| ## Implementation | ||||
|  | ||||
| ### Example Usage | ||||
|  | ||||
| <ha-marquee-text style="width: 200px;"> | ||||
|     This is a long text that will scroll horizontally if it overflows the container. | ||||
| </ha-marquee-text> | ||||
|  | ||||
| ```html | ||||
| <ha-marquee-text style="width: 200px;"> | ||||
|   This is a long text that will scroll horizontally if it overflows the | ||||
|   container. | ||||
| </ha-marquee-text> | ||||
| ``` | ||||
|  | ||||
| ### API | ||||
|  | ||||
| **Slots** | ||||
|  | ||||
| - default slot: The text content to be displayed and scrolled. | ||||
|   - no default | ||||
|  | ||||
| **Properties/Attributes** | ||||
|  | ||||
| | Name           | Type    | Default | Description                                                                  | | ||||
| | -------------- | ------- | ------- | ---------------------------------------------------------------------------- | | ||||
| | speed          | number  | `15`    | The speed of the scrolling animation. Higher values result in faster scroll. | | ||||
| | pause-on-hover | boolean | `true`  | Whether to pause the scrolling animation when                                | | ||||
| | pause-duration | number  | `1000`  | The delay in milliseconds before the scrolling animation starts/restarts.    | | ||||
							
								
								
									
										25
									
								
								gallery/src/pages/components/ha-marquee-text.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								gallery/src/pages/components/ha-marquee-text.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { css, LitElement } from "lit"; | ||||
| import { customElement } from "lit/decorators"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-marquee-text"; | ||||
|  | ||||
| @customElement("demo-components-ha-marquee-text") | ||||
| export class DemoHaMarqueeText extends LitElement { | ||||
|   static styles = css` | ||||
|     ha-card { | ||||
|       max-width: 600px; | ||||
|       margin: 24px auto; | ||||
|     } | ||||
|     .card-content { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: flex-start; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "demo-components-ha-marquee-text": DemoHaMarqueeText; | ||||
|   } | ||||
| } | ||||
| @@ -123,11 +123,11 @@ export class DemoHaProgressButton extends LitElement { | ||||
|     .card-content { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 24px; | ||||
|       gap: var(--ha-space-6); | ||||
|     } | ||||
|     .card-content div { | ||||
|       display: flex; | ||||
|       gap: 8px; | ||||
|       gap: var(--ha-space-2); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|   | ||||
| @@ -131,7 +131,7 @@ export class DemoHaSelectBox extends LitElement { | ||||
|       --mdc-icon-size: 24px; | ||||
|       --control-select-color: var(--state-fan-active-color); | ||||
|       --control-select-thickness: 130px; | ||||
|       --control-select-border-radius: 36px; | ||||
|       --control-select-border-radius: var(--ha-border-radius-6xl); | ||||
|     } | ||||
|  | ||||
|     p.title { | ||||
|   | ||||
							
								
								
									
										38
									
								
								gallery/src/pages/components/ha-slider.markdown
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								gallery/src/pages/components/ha-slider.markdown
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| --- | ||||
| title: Slider | ||||
| subtitle: A slider component for selecting a value from a range. | ||||
| --- | ||||
|  | ||||
| <style> | ||||
|   .wrapper { | ||||
|     display: flex; | ||||
|     gap: 24px; | ||||
|   } | ||||
| </style> | ||||
|  | ||||
| # Slider `<ha-slider>` | ||||
|  | ||||
| ## Implementation | ||||
|  | ||||
| ### Example Usage | ||||
|  | ||||
| <div class="wrapper"> | ||||
|   <ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider> | ||||
|   <ha-slider size="medium"></ha-slider> | ||||
| </div> | ||||
|  | ||||
| ```html | ||||
| <ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider> | ||||
| <ha-slider size="medium"></ha-slider> | ||||
| ``` | ||||
|  | ||||
| ### API | ||||
|  | ||||
| This component is based on the webawesome slider component. | ||||
| Check the [webawesome documentation](https://webawesome.com/docs/components/slider/) for more details. | ||||
|  | ||||
| **CSS Custom Properties** | ||||
|  | ||||
| - `--ha-slider-track-size` - Height of the slider track. Defaults to `4px`. | ||||
| - `--ha-slider-thumb-color` - Color of the slider thumb. Defaults to `var(--primary-color)`. | ||||
| - `--ha-slider-indicator-color` - Color of the filled portion of the slider track. Defaults to `var(--primary-color)`. | ||||
							
								
								
									
										100
									
								
								gallery/src/pages/components/ha-slider.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								gallery/src/pages/components/ha-slider.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| 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-bar"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-spinner"; | ||||
| import "../../../../src/components/ha-slider"; | ||||
| import type { HomeAssistant } from "../../../../src/types"; | ||||
|  | ||||
| @customElement("demo-components-ha-slider") | ||||
| export class DemoHaSlider extends LitElement { | ||||
|   @property({ attribute: false }) hass!: HomeAssistant; | ||||
|  | ||||
|   protected render(): TemplateResult { | ||||
|     return html` | ||||
|       ${["light", "dark"].map( | ||||
|         (mode) => html` | ||||
|           <div class=${mode}> | ||||
|             <ha-card header="ha-slider ${mode} demo"> | ||||
|               <div class="card-content"> | ||||
|                 <span>Default (disabled)</span> | ||||
|                 <ha-slider | ||||
|                   disabled | ||||
|                   min="0" | ||||
|                   max="8" | ||||
|                   value="4" | ||||
|                   with-markers | ||||
|                 ></ha-slider> | ||||
|                 <span>Small</span> | ||||
|                 <ha-slider | ||||
|                   size="small" | ||||
|                   min="0" | ||||
|                   max="8" | ||||
|                   value="4" | ||||
|                   with-markers | ||||
|                 ></ha-slider> | ||||
|                 <span>Medium</span> | ||||
|                 <ha-slider | ||||
|                   size="medium" | ||||
|                   min="0" | ||||
|                   max="8" | ||||
|                   value="4" | ||||
|                   with-markers | ||||
|                 ></ha-slider> | ||||
|               </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; | ||||
|       align-items: center; | ||||
|       gap: var(--ha-space-6); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "demo-components-ha-slider": DemoHaSlider; | ||||
|   } | ||||
| } | ||||
| @@ -61,7 +61,7 @@ export class DemoHaSpinner extends LitElement { | ||||
|       background-color: var(--primary-background-color); | ||||
|       padding: 0 50px; | ||||
|       margin: 16px; | ||||
|       border-radius: 8px; | ||||
|       border-radius: var(--ha-border-radius-md); | ||||
|     } | ||||
|     ha-card { | ||||
|       margin: 24px auto; | ||||
| @@ -70,7 +70,7 @@ export class DemoHaSpinner extends LitElement { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: center; | ||||
|       gap: 24px; | ||||
|       gap: var(--ha-space-6); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|   | ||||
							
								
								
									
										3
									
								
								gallery/src/pages/components/ha-wa-dialog.markdown
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								gallery/src/pages/components/ha-wa-dialog.markdown
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| --- | ||||
| title: Dialog (ha-wa-dialog) | ||||
| --- | ||||
							
								
								
									
										523
									
								
								gallery/src/pages/components/ha-wa-dialog.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										523
									
								
								gallery/src/pages/components/ha-wa-dialog.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,523 @@ | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement, state } from "lit/decorators"; | ||||
| import { mdiCog, mdiHelp } from "@mdi/js"; | ||||
| import "../../../../src/components/ha-button"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-dialog-footer"; | ||||
| import "../../../../src/components/ha-form/ha-form"; | ||||
| import "../../../../src/components/ha-icon-button"; | ||||
| import "../../../../src/components/ha-wa-dialog"; | ||||
| import type { HaFormSchema } from "../../../../src/components/ha-form/types"; | ||||
|  | ||||
| const SCHEMA: HaFormSchema[] = [ | ||||
|   { type: "string", name: "Name", default: "", autofocus: true }, | ||||
|   { type: "string", name: "Email", default: "" }, | ||||
| ]; | ||||
|  | ||||
| type DialogType = | ||||
|   | false | ||||
|   | "basic" | ||||
|   | "basic-subtitle-below" | ||||
|   | "basic-subtitle-above" | ||||
|   | "form" | ||||
|   | "actions"; | ||||
|  | ||||
| @customElement("demo-components-ha-wa-dialog") | ||||
| export class DemoHaWaDialog extends LitElement { | ||||
|   @state() private _openDialog: DialogType = false; | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <div class="content"> | ||||
|         <h1>Dialog <code><ha-wa-dialog></code></h1> | ||||
|  | ||||
|         <p class="subtitle">Dialog component built with WebAwesome.</p> | ||||
|  | ||||
|         <h2>Demos</h2> | ||||
|  | ||||
|         <div class="buttons"> | ||||
|           <ha-button @click=${this._handleOpenDialog("basic")} | ||||
|             >Basic dialog</ha-button | ||||
|           > | ||||
|           <ha-button @click=${this._handleOpenDialog("basic-subtitle-below")} | ||||
|             >Basic dialog with subtitle below</ha-button | ||||
|           > | ||||
|           <ha-button @click=${this._handleOpenDialog("basic-subtitle-above")} | ||||
|             >Basic dialog with subtitle above</ha-button | ||||
|           > | ||||
|           <ha-button @click=${this._handleOpenDialog("form")} | ||||
|             >Dialog with form</ha-button | ||||
|           > | ||||
|           <ha-button @click=${this._handleOpenDialog("actions")} | ||||
|             >Dialog with actions</ha-button | ||||
|           > | ||||
|         </div> | ||||
|  | ||||
|         <ha-wa-dialog | ||||
|           .open=${this._openDialog === "basic"} | ||||
|           header-title="Basic dialog" | ||||
|           @closed=${this._handleClosed} | ||||
|         > | ||||
|           <div>Dialog content</div> | ||||
|         </ha-wa-dialog> | ||||
|  | ||||
|         <ha-wa-dialog | ||||
|           .open=${this._openDialog === "basic-subtitle-below"} | ||||
|           header-title="Basic dialog with subtitle" | ||||
|           header-subtitle="This is a basic dialog with a subtitle below" | ||||
|           @closed=${this._handleClosed} | ||||
|         > | ||||
|           <div>Dialog content</div> | ||||
|         </ha-wa-dialog> | ||||
|  | ||||
|         <ha-wa-dialog | ||||
|           .open=${this._openDialog === "basic-subtitle-above"} | ||||
|           header-title="Dialog with subtitle above" | ||||
|           header-subtitle="This is a basic dialog with a subtitle above" | ||||
|           header-subtitle-position="above" | ||||
|           @closed=${this._handleClosed} | ||||
|         > | ||||
|           <div>Dialog content</div> | ||||
|         </ha-wa-dialog> | ||||
|  | ||||
|         <ha-wa-dialog | ||||
|           .open=${this._openDialog === "form"} | ||||
|           header-title="Dialog with form" | ||||
|           header-subtitle="This is a dialog with a form and a footer" | ||||
|           prevent-scrim-close | ||||
|           @closed=${this._handleClosed} | ||||
|         > | ||||
|           <ha-form autofocus .schema=${SCHEMA}></ha-form> | ||||
|           <ha-dialog-footer slot="footer"> | ||||
|             <ha-button | ||||
|               data-dialog="close" | ||||
|               slot="secondaryAction" | ||||
|               variant="plain" | ||||
|               >Cancel</ha-button | ||||
|             > | ||||
|             <ha-button data-dialog="close" slot="primaryAction" variant="accent" | ||||
|               >Submit</ha-button | ||||
|             > | ||||
|           </ha-dialog-footer> | ||||
|         </ha-wa-dialog> | ||||
|  | ||||
|         <ha-wa-dialog | ||||
|           .open=${this._openDialog === "actions"} | ||||
|           header-title="Dialog with actions" | ||||
|           header-subtitle="This is a dialog with header actions" | ||||
|           @closed=${this._handleClosed} | ||||
|         > | ||||
|           <div slot="headerActionItems"> | ||||
|             <ha-icon-button label="Settings" path=${mdiCog}></ha-icon-button> | ||||
|             <ha-icon-button label="Help" path=${mdiHelp}></ha-icon-button> | ||||
|           </div> | ||||
|  | ||||
|           <div>Dialog content</div> | ||||
|         </ha-wa-dialog> | ||||
|  | ||||
|         <h2>Design</h2> | ||||
|  | ||||
|         <h3>Width</h3> | ||||
|  | ||||
|         <p>There are multiple widths available for the dialog.</p> | ||||
|  | ||||
|         <table> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Name</th> | ||||
|               <th>Value</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td><code>small</code></td> | ||||
|               <td><code>min(320px, var(--full-width))</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>medium</code></td> | ||||
|               <td><code>min(580px, var(--full-width))</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>large</code></td> | ||||
|               <td><code>min(720px, var(--full-width))</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>full</code></td> | ||||
|               <td><code>var(--full-width)</code></td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <p> | ||||
|           <code>--full-width</code> is calculated based on the available width | ||||
|           of the screen. 95vw is the maximum width of the dialog on a large | ||||
|           screen, while on a small screen it is 100vw minus the safe area | ||||
|           insets. | ||||
|         </p> | ||||
|  | ||||
|         <p>Dialogs have a default width of <code>medium</code>.</p> | ||||
|  | ||||
|         <h3>Prevent scrim close</h3> | ||||
|  | ||||
|         <p> | ||||
|           You can prevent the dialog from being closed by clicking the | ||||
|           scrim/overlay. This is allowed by default. | ||||
|         </p> | ||||
|  | ||||
|         <h3>Header</h3> | ||||
|  | ||||
|         <p>The header contains a title, a subtitle and action items.</p> | ||||
|  | ||||
|         <table> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Slot</th> | ||||
|               <th>Description</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td><code>header</code></td> | ||||
|               <td>The entire header area.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>headerTitle</code></td> | ||||
|               <td>The header title text.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>headerSubtitle</code></td> | ||||
|               <td>The header subtitle text.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>headerActionItems</code></td> | ||||
|               <td>The header action items.</td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h4>Header title</h4> | ||||
|  | ||||
|         <p>The header title is a text string.</p> | ||||
|  | ||||
|         <h4>Header subtitle</h4> | ||||
|  | ||||
|         <p>The header subtitle is a text string.</p> | ||||
|  | ||||
|         <h4>Header action items</h4> | ||||
|  | ||||
|         <p> | ||||
|           The header action items usually containing icon buttons and/or menu | ||||
|           buttons. | ||||
|         </p> | ||||
|  | ||||
|         <h3>Body</h3> | ||||
|  | ||||
|         <p>The body is the content of the dialog.</p> | ||||
|  | ||||
|         <h3>Footer</h3> | ||||
|  | ||||
|         <p>The footer is the footer of the dialog.</p> | ||||
|  | ||||
|         <p> | ||||
|           It is recommended to use the <code>ha-dialog-footer</code> component | ||||
|           for the footer and to style the buttons inside the footer as so: | ||||
|         </p> | ||||
|  | ||||
|         <table> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Slot</th> | ||||
|               <th>Description</th> | ||||
|               <th>Variant to use</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td><code>secondaryAction</code></td> | ||||
|               <td>The secondary action button(s).</td> | ||||
|               <td><code>plain</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>primaryAction</code></td> | ||||
|               <td>The primary action button(s).</td> | ||||
|               <td><code>accent</code></td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h2>Implementation</h2> | ||||
|  | ||||
|         <h3>Example Usage</h3> | ||||
|  | ||||
|         <pre><code><ha-wa-dialog | ||||
|   open | ||||
|   header-title="Dialog title" | ||||
|   header-subtitle="Dialog subtitle" | ||||
|   prevent-scrim-close | ||||
| > | ||||
|   <div slot="headerActionItems"> | ||||
|     <ha-icon-button label="Settings" path="mdiCog"></ha-icon-button> | ||||
|     <ha-icon-button label="Help" path="mdiHelp"></ha-icon-button> | ||||
|   </div> | ||||
|   <div>Dialog content</div> | ||||
|   <ha-dialog-footer slot="footer"> | ||||
|     <ha-button data-dialog="close" slot="secondaryAction" variant="plain" | ||||
|       >Cancel</ha-button | ||||
|     > | ||||
|     <ha-button slot="primaryAction" variant="accent">Submit</ha-button> | ||||
|   </ha-dialog-footer> | ||||
| </ha-wa-dialog></code></pre> | ||||
|  | ||||
|         <h3>API</h3> | ||||
|  | ||||
|         <p> | ||||
|           This component is based on the webawesome dialog component. Check the | ||||
|           <a | ||||
|             href="https://webawesome.com/docs/components/dialog/" | ||||
|             target="_blank" | ||||
|             rel="noopener noreferrer" | ||||
|             >webawesome documentation</a | ||||
|           > | ||||
|           for more details. | ||||
|         </p> | ||||
|  | ||||
|         <h4>Attributes</h4> | ||||
|  | ||||
|         <table> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Attribute</th> | ||||
|               <th>Description</th> | ||||
|               <th>Default</th> | ||||
|               <th>Options</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td><code>open</code></td> | ||||
|               <td>Controls the dialog open state.</td> | ||||
|               <td><code>false</code></td> | ||||
|               <td><code>false</code>, <code>true</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>width</code></td> | ||||
|               <td>Preferred dialog width preset.</td> | ||||
|               <td><code>medium</code></td> | ||||
|               <td> | ||||
|                 <code>small</code>, <code>medium</code>, <code>large</code>, | ||||
|                 <code>full</code> | ||||
|               </td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>prevent-scrim-close</code></td> | ||||
|               <td> | ||||
|                 Prevents closing the dialog by clicking the scrim/overlay. | ||||
|               </td> | ||||
|               <td><code>false</code></td> | ||||
|               <td><code>true</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>header-title</code></td> | ||||
|               <td>Header title text when no custom title slot is provided.</td> | ||||
|               <td></td> | ||||
|               <td></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>header-subtitle</code></td> | ||||
|               <td> | ||||
|                 Header subtitle text when no custom subtitle slot is provided. | ||||
|               </td> | ||||
|               <td></td> | ||||
|               <td></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>header-subtitle-position</code></td> | ||||
|               <td>Position of the subtitle relative to the title.</td> | ||||
|               <td><code>below</code></td> | ||||
|               <td><code>above</code>, <code>below</code></td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>flexcontent</code></td> | ||||
|               <td> | ||||
|                 Makes the dialog body a flex container for flexible layouts. | ||||
|               </td> | ||||
|               <td><code>false</code></td> | ||||
|               <td><code>false</code>, <code>true</code></td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h4>CSS Custom Properties</h4> | ||||
|  | ||||
|         <table> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>CSS Property</th> | ||||
|               <th>Description</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td><code>--dialog-content-padding</code></td> | ||||
|               <td>Padding for dialog content sections.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--ha-dialog-show-duration</code></td> | ||||
|               <td>Show animation duration.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--ha-dialog-hide-duration</code></td> | ||||
|               <td>Hide animation duration.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--ha-dialog-surface-background</code></td> | ||||
|               <td>Dialog background color.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--ha-dialog-border-radius</code></td> | ||||
|               <td>Border radius of the dialog surface.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--dialog-z-index</code></td> | ||||
|               <td>Z-index for the dialog.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--dialog-surface-position</code></td> | ||||
|               <td>CSS position of the dialog surface.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>--dialog-surface-margin-top</code></td> | ||||
|               <td>Top margin for the dialog surface.</td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|  | ||||
|         <h4>Events</h4> | ||||
|  | ||||
|         <table> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Event</th> | ||||
|               <th>Description</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             <tr> | ||||
|               <td><code>opened</code></td> | ||||
|               <td>Fired when the dialog is shown.</td> | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <td><code>closed</code></td> | ||||
|               <td>Fired after the dialog is hidden.</td> | ||||
|             </tr> | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _handleOpenDialog = (dialog: DialogType) => () => { | ||||
|     this._openDialog = dialog; | ||||
|   }; | ||||
|  | ||||
|   private _handleClosed = () => { | ||||
|     this._openDialog = false; | ||||
|   }; | ||||
|  | ||||
|   static styles = [ | ||||
|     css` | ||||
|       :host { | ||||
|         display: block; | ||||
|         padding: var(--ha-space-4); | ||||
|       } | ||||
|  | ||||
|       .content { | ||||
|         max-width: 1000px; | ||||
|         margin: 0 auto; | ||||
|       } | ||||
|  | ||||
|       h1 { | ||||
|         margin-top: 0; | ||||
|         margin-bottom: var(--ha-space-2); | ||||
|       } | ||||
|  | ||||
|       h2 { | ||||
|         margin-top: var(--ha-space-6); | ||||
|         margin-bottom: var(--ha-space-3); | ||||
|       } | ||||
|  | ||||
|       h3, | ||||
|       h4 { | ||||
|         margin-top: var(--ha-space-4); | ||||
|         margin-bottom: var(--ha-space-2); | ||||
|       } | ||||
|  | ||||
|       p { | ||||
|         margin: var(--ha-space-2) 0; | ||||
|         line-height: 1.6; | ||||
|       } | ||||
|  | ||||
|       .subtitle { | ||||
|         color: var(--secondary-text-color); | ||||
|         font-size: 1.1em; | ||||
|         margin-bottom: var(--ha-space-4); | ||||
|       } | ||||
|  | ||||
|       table { | ||||
|         width: 100%; | ||||
|         border-collapse: collapse; | ||||
|         margin: var(--ha-space-3) 0; | ||||
|       } | ||||
|  | ||||
|       th, | ||||
|       td { | ||||
|         text-align: left; | ||||
|         padding: var(--ha-space-2); | ||||
|         border-bottom: 1px solid var(--divider-color); | ||||
|       } | ||||
|  | ||||
|       th { | ||||
|         font-weight: 500; | ||||
|       } | ||||
|  | ||||
|       code { | ||||
|         background-color: var(--secondary-background-color); | ||||
|         padding: 2px 6px; | ||||
|         border-radius: 4px; | ||||
|         font-family: monospace; | ||||
|         font-size: 0.9em; | ||||
|       } | ||||
|  | ||||
|       pre { | ||||
|         background-color: var(--secondary-background-color); | ||||
|         padding: var(--ha-space-3); | ||||
|         border-radius: 8px; | ||||
|         overflow-x: auto; | ||||
|         margin: var(--ha-space-3) 0; | ||||
|       } | ||||
|  | ||||
|       pre code { | ||||
|         background-color: transparent; | ||||
|         padding: 0; | ||||
|       } | ||||
|  | ||||
|       .buttons { | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
|         flex-wrap: wrap; | ||||
|         gap: var(--ha-space-2); | ||||
|         margin: var(--ha-space-4) 0; | ||||
|       } | ||||
|  | ||||
|       a { | ||||
|         color: var(--primary-color); | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "demo-components-ha-wa-dialog": DemoHaWaDialog; | ||||
|   } | ||||
| } | ||||
| @@ -5,13 +5,13 @@ import type { | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { mockIcons } from "../../../../demo/src/stubs/icons"; | ||||
| import { computeDomain } from "../../../../src/common/entity/compute_domain"; | ||||
| import { computeStateDisplay } from "../../../../src/common/entity/compute_state_display"; | ||||
| import "../../../../src/components/data-table/ha-data-table"; | ||||
| import type { DataTableColumnContainer } from "../../../../src/components/data-table/ha-data-table"; | ||||
| import "../../../../src/components/entity/state-badge"; | ||||
| import { provideHass } from "../../../../src/fake_data/provide_hass"; | ||||
| import { mockIcons } from "../../../../demo/src/stubs/icons"; | ||||
| import type { HomeAssistant } from "../../../../src/types"; | ||||
|  | ||||
| const SENSOR_DEVICE_CLASSES = [ | ||||
| @@ -434,7 +434,7 @@ export class DemoEntityState extends LitElement { | ||||
|       display: block; | ||||
|       height: 20px; | ||||
|       width: 20px; | ||||
|       border-radius: 10px; | ||||
|       border-radius: var(--ha-border-radius-md); | ||||
|       background-color: rgb(--color); | ||||
|     } | ||||
|   `; | ||||
|   | ||||
| @@ -11,7 +11,10 @@ import "../../../../src/components/ha-alert"; | ||||
| import "../../../../src/components/ha-button-menu"; | ||||
| import "../../../../src/components/ha-card"; | ||||
| import "../../../../src/components/ha-form/ha-form"; | ||||
| import type { HaFormSchema } from "../../../../src/components/ha-form/types"; | ||||
| import type { | ||||
|   HaFormSchema, | ||||
|   HaFormDataContainer, | ||||
| } from "../../../../src/components/ha-form/types"; | ||||
| import "../../../../src/components/ha-formfield"; | ||||
| import "../../../../src/components/ha-icon-button"; | ||||
| import "../../../../src/components/ha-list-item"; | ||||
| @@ -33,6 +36,7 @@ import { haStyle } from "../../../../src/resources/styles"; | ||||
| import type { HomeAssistant } from "../../../../src/types"; | ||||
| import { suggestAddonRestart } from "../../dialogs/suggestAddonRestart"; | ||||
| import { hassioStyle } from "../../resources/hassio-style"; | ||||
| import type { ObjectSelector, Selector } from "../../../../src/data/selector"; | ||||
|  | ||||
| const SUPPORTED_UI_TYPES = [ | ||||
|   "string", | ||||
| @@ -78,77 +82,124 @@ class HassioAddonConfig extends LitElement { | ||||
|  | ||||
|   @query("ha-yaml-editor") private _editor?: HaYamlEditor; | ||||
|  | ||||
|   public computeLabel = (entry: HaFormSchema): string => | ||||
|     this.addon.translations[this.hass.language]?.configuration?.[entry.name] | ||||
|       ?.name || | ||||
|     this.addon.translations.en?.configuration?.[entry.name]?.name || | ||||
|   private _getTranslationEntry( | ||||
|     language: string, | ||||
|     entry: HaFormSchema, | ||||
|     options?: { path?: string[] } | ||||
|   ) { | ||||
|     let parent = this.addon.translations[language]?.configuration; | ||||
|     if (!parent) return undefined; | ||||
|     if (options?.path) { | ||||
|       for (const key of options.path) { | ||||
|         parent = parent[key]?.fields; | ||||
|         if (!parent) return undefined; | ||||
|       } | ||||
|     } | ||||
|     return parent[entry.name]; | ||||
|   } | ||||
|  | ||||
|   public computeLabel = ( | ||||
|     entry: HaFormSchema, | ||||
|     _data: HaFormDataContainer, | ||||
|     options?: { path?: string[] } | ||||
|   ): string => | ||||
|     this._getTranslationEntry(this.hass.language, entry, options)?.name || | ||||
|     this._getTranslationEntry("en", entry, options)?.name || | ||||
|     entry.name; | ||||
|  | ||||
|   public computeHelper = (entry: HaFormSchema): string => | ||||
|     this.addon.translations[this.hass.language]?.configuration?.[entry.name] | ||||
|   public computeHelper = ( | ||||
|     entry: HaFormSchema, | ||||
|     options?: { path?: string[] } | ||||
|   ): string => | ||||
|     this._getTranslationEntry(this.hass.language, entry, options) | ||||
|       ?.description || | ||||
|     this.addon.translations.en?.configuration?.[entry.name]?.description || | ||||
|     this._getTranslationEntry("en", entry, options)?.description || | ||||
|     ""; | ||||
|  | ||||
|   private _convertSchema = memoizeOne( | ||||
|     // Convert supervisor schema to selectors | ||||
|     (schema: Record<string, any>): HaFormSchema[] => | ||||
|       schema.map((entry) => | ||||
|         entry.type === "select" | ||||
|           ? { | ||||
|               name: entry.name, | ||||
|               required: entry.required, | ||||
|               selector: { select: { options: entry.options } }, | ||||
|             } | ||||
|           : entry.type === "string" | ||||
|             ? entry.multiple | ||||
|               ? { | ||||
|                   name: entry.name, | ||||
|                   required: entry.required, | ||||
|                   selector: { | ||||
|                     select: { options: [], multiple: true, custom_value: true }, | ||||
|                   }, | ||||
|                 } | ||||
|               : { | ||||
|                   name: entry.name, | ||||
|                   required: entry.required, | ||||
|                   selector: { | ||||
|                     text: { | ||||
|                       type: entry.format | ||||
|                         ? entry.format | ||||
|                         : MASKED_FIELDS.includes(entry.name) | ||||
|                           ? "password" | ||||
|                           : "text", | ||||
|                     }, | ||||
|                   }, | ||||
|                 } | ||||
|             : entry.type === "boolean" | ||||
|               ? { | ||||
|                   name: entry.name, | ||||
|                   required: entry.required, | ||||
|                   selector: { boolean: {} }, | ||||
|                 } | ||||
|               : entry.type === "schema" | ||||
|                 ? { | ||||
|                     name: entry.name, | ||||
|                     required: entry.required, | ||||
|                     selector: { object: {} }, | ||||
|                   } | ||||
|                 : entry.type === "float" || entry.type === "integer" | ||||
|                   ? { | ||||
|                       name: entry.name, | ||||
|                       required: entry.required, | ||||
|                       selector: { | ||||
|                         number: { | ||||
|                           mode: "box", | ||||
|                           step: entry.type === "float" ? "any" : undefined, | ||||
|                         }, | ||||
|                       }, | ||||
|                     } | ||||
|                   : entry | ||||
|       ) | ||||
|     (schema: readonly HaFormSchema[]): HaFormSchema[] => | ||||
|       this._convertSchemaElements(schema) | ||||
|   ); | ||||
|  | ||||
|   private _convertSchemaElements( | ||||
|     schema: readonly HaFormSchema[] | ||||
|   ): HaFormSchema[] { | ||||
|     return schema.map((entry) => this._convertSchemaElement(entry)); | ||||
|   } | ||||
|  | ||||
|   private _convertSchemaElement(entry: any): HaFormSchema { | ||||
|     if (entry.type === "schema" && !entry.multiple) { | ||||
|       return { | ||||
|         name: entry.name, | ||||
|         type: "expandable", | ||||
|         required: entry.required, | ||||
|         schema: this._convertSchemaElements(entry.schema), | ||||
|       }; | ||||
|     } | ||||
|     const selector = this._convertSchemaElementToSelector(entry, false); | ||||
|     if (selector) { | ||||
|       return { | ||||
|         name: entry.name, | ||||
|         required: entry.required, | ||||
|         selector, | ||||
|       }; | ||||
|     } | ||||
|     return entry; | ||||
|   } | ||||
|  | ||||
|   private _convertSchemaElementToSelector( | ||||
|     entry: any, | ||||
|     force: boolean | ||||
|   ): Selector | null { | ||||
|     if (entry.type === "select") { | ||||
|       return { select: { options: entry.options } }; | ||||
|     } | ||||
|     if (entry.type === "string") { | ||||
|       return entry.multiple | ||||
|         ? { select: { options: [], multiple: true, custom_value: true } } | ||||
|         : { | ||||
|             text: { | ||||
|               type: entry.format | ||||
|                 ? entry.format | ||||
|                 : MASKED_FIELDS.includes(entry.name) | ||||
|                   ? "password" | ||||
|                   : "text", | ||||
|             }, | ||||
|           }; | ||||
|     } | ||||
|     if (entry.type === "boolean") { | ||||
|       return { boolean: {} }; | ||||
|     } | ||||
|     if (entry.type === "schema") { | ||||
|       const fields: NonNullable<ObjectSelector["object"]>["fields"] = {}; | ||||
|       for (const child_entry of entry.schema) { | ||||
|         fields[child_entry.name] = { | ||||
|           required: child_entry.required, | ||||
|           selector: this._convertSchemaElementToSelector(child_entry, true)!, | ||||
|         }; | ||||
|       } | ||||
|       return { | ||||
|         object: { | ||||
|           multiple: entry.multiple, | ||||
|           fields, | ||||
|         }, | ||||
|       }; | ||||
|     } | ||||
|     if (entry.type === "float" || entry.type === "integer") { | ||||
|       return { | ||||
|         number: { | ||||
|           mode: "box", | ||||
|           step: entry.type === "float" ? "any" : undefined, | ||||
|         }, | ||||
|       }; | ||||
|     } | ||||
|     if (force) { | ||||
|       return { object: {} }; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   private _filteredSchema = memoizeOne( | ||||
|     (options: Record<string, unknown>, schema: HaFormSchema[]) => | ||||
|       schema.filter((entry) => entry.name in options || entry.required) | ||||
|   | ||||
| @@ -121,7 +121,7 @@ class HassioCardContent extends LitElement { | ||||
|       height: 12px; | ||||
|       top: 8px; | ||||
|       right: 8px; | ||||
|       border-radius: 50%; | ||||
|       border-radius: var(--ha-border-radius-circle); | ||||
|     } | ||||
|     .topbar { | ||||
|       position: absolute; | ||||
|   | ||||
| @@ -164,7 +164,7 @@ class HassioHardwareDialog extends LitElement { | ||||
|         pre, | ||||
|         code { | ||||
|           background-color: var(--markdown-code-background-color, none); | ||||
|           border-radius: 3px; | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|         } | ||||
|         pre { | ||||
|           padding: 16px; | ||||
|   | ||||
| @@ -228,7 +228,7 @@ class HassioRegistriesDialog extends LitElement { | ||||
|       css` | ||||
|         .registry { | ||||
|           border: 1px solid var(--divider-color); | ||||
|           border-radius: 4px; | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|           margin-top: 4px; | ||||
|         } | ||||
|         .action { | ||||
|   | ||||
| @@ -193,7 +193,7 @@ class HassioRepositoriesDialog extends LitElement { | ||||
|         } | ||||
|         .option { | ||||
|           border: 1px solid var(--divider-color); | ||||
|           border-radius: 4px; | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|           margin-top: 4px; | ||||
|         } | ||||
|         ha-button { | ||||
|   | ||||
| @@ -159,7 +159,7 @@ class HassioSystemManagedDialog extends LitElement { | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
|           align-items: center; | ||||
|           gap: 16px; | ||||
|           gap: var(--ha-space-4); | ||||
|           --mdc-icon-size: 48px; | ||||
|           margin-bottom: 32px; | ||||
|         } | ||||
|   | ||||
| @@ -31,7 +31,7 @@ export const hassioStyle = css` | ||||
|   .card-group { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | ||||
|     grid-gap: 8px; | ||||
|     grid-gap: var(--ha-space-2); | ||||
|   } | ||||
|   @media screen and (min-width: 640px) { | ||||
|     .card-group { | ||||
|   | ||||
| @@ -302,7 +302,7 @@ class LandingPageLogs extends LitElement { | ||||
|         max-height: 300px; | ||||
|         overflow: auto; | ||||
|         border: 1px solid var(--divider-color); | ||||
|         border-radius: 4px; | ||||
|         border-radius: var(--ha-border-radius-sm); | ||||
|         padding: 4px; | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -213,7 +213,7 @@ class HaLandingPage extends LandingPageBaseElement { | ||||
|       ha-card .card-content { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 16px; | ||||
|         gap: var(--ha-space-4); | ||||
|       } | ||||
|       ha-alert p { | ||||
|         text-align: unset; | ||||
| @@ -221,7 +221,7 @@ class HaLandingPage extends LandingPageBaseElement { | ||||
|       ha-language-picker { | ||||
|         display: block; | ||||
|         width: 200px; | ||||
|         border-radius: 4px; | ||||
|         border-radius: var(--ha-border-radius-sm); | ||||
|         overflow: hidden; | ||||
|         --ha-select-height: 40px; | ||||
|         --mdc-select-fill-color: none; | ||||
|   | ||||
							
								
								
									
										106
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										106
									
								
								package.json
									
									
									
									
									
								
							| @@ -28,31 +28,32 @@ | ||||
|   "dependencies": { | ||||
|     "@babel/runtime": "7.28.4", | ||||
|     "@braintree/sanitize-url": "7.1.1", | ||||
|     "@codemirror/autocomplete": "6.18.7", | ||||
|     "@codemirror/commands": "6.8.1", | ||||
|     "@codemirror/autocomplete": "6.19.1", | ||||
|     "@codemirror/commands": "6.10.0", | ||||
|     "@codemirror/language": "6.11.3", | ||||
|     "@codemirror/legacy-modes": "6.5.1", | ||||
|     "@codemirror/legacy-modes": "6.5.2", | ||||
|     "@codemirror/search": "6.5.11", | ||||
|     "@codemirror/state": "6.5.2", | ||||
|     "@codemirror/view": "6.38.2", | ||||
|     "@codemirror/view": "6.38.6", | ||||
|     "@date-fns/tz": "1.4.1", | ||||
|     "@egjs/hammerjs": "2.0.17", | ||||
|     "@formatjs/intl-datetimeformat": "6.18.0", | ||||
|     "@formatjs/intl-displaynames": "6.8.11", | ||||
|     "@formatjs/intl-durationformat": "0.7.4", | ||||
|     "@formatjs/intl-getcanonicallocales": "2.5.5", | ||||
|     "@formatjs/intl-listformat": "7.7.11", | ||||
|     "@formatjs/intl-locale": "4.2.11", | ||||
|     "@formatjs/intl-numberformat": "8.15.4", | ||||
|     "@formatjs/intl-pluralrules": "5.4.4", | ||||
|     "@formatjs/intl-relativetimeformat": "11.4.11", | ||||
|     "@formatjs/intl-datetimeformat": "6.18.2", | ||||
|     "@formatjs/intl-displaynames": "6.8.13", | ||||
|     "@formatjs/intl-durationformat": "0.7.6", | ||||
|     "@formatjs/intl-getcanonicallocales": "2.5.6", | ||||
|     "@formatjs/intl-listformat": "7.7.13", | ||||
|     "@formatjs/intl-locale": "4.2.13", | ||||
|     "@formatjs/intl-numberformat": "8.15.6", | ||||
|     "@formatjs/intl-pluralrules": "5.4.6", | ||||
|     "@formatjs/intl-relativetimeformat": "11.4.13", | ||||
|     "@fullcalendar/core": "6.1.19", | ||||
|     "@fullcalendar/daygrid": "6.1.19", | ||||
|     "@fullcalendar/interaction": "6.1.19", | ||||
|     "@fullcalendar/list": "6.1.19", | ||||
|     "@fullcalendar/luxon3": "6.1.19", | ||||
|     "@fullcalendar/timegrid": "6.1.19", | ||||
|     "@home-assistant/webawesome": "3.0.0-beta.4.ha.3", | ||||
|     "@lezer/highlight": "1.2.1", | ||||
|     "@home-assistant/webawesome": "3.0.0-beta.6.ha.6", | ||||
|     "@lezer/highlight": "1.2.3", | ||||
|     "@lit-labs/motion": "1.0.9", | ||||
|     "@lit-labs/observers": "2.0.6", | ||||
|     "@lit-labs/virtualizer": "2.1.1", | ||||
| @@ -88,21 +89,20 @@ | ||||
|     "@thomasloven/round-slider": "0.6.0", | ||||
|     "@tsparticles/engine": "3.9.1", | ||||
|     "@tsparticles/preset-links": "3.2.0", | ||||
|     "@vaadin/combo-box": "24.8.7", | ||||
|     "@vaadin/vaadin-themable-mixin": "24.8.7", | ||||
|     "@vaadin/combo-box": "24.9.2", | ||||
|     "@vaadin/vaadin-themable-mixin": "24.9.2", | ||||
|     "@vibrant/color": "4.0.0", | ||||
|     "@vue/web-component-wrapper": "1.3.0", | ||||
|     "@webcomponents/scoped-custom-element-registry": "0.0.10", | ||||
|     "@webcomponents/webcomponentsjs": "2.8.0", | ||||
|     "app-datepicker": "5.1.1", | ||||
|     "barcode-detector": "3.0.5", | ||||
|     "color-name": "2.0.0", | ||||
|     "barcode-detector": "3.0.6", | ||||
|     "color-name": "2.0.2", | ||||
|     "comlink": "4.4.2", | ||||
|     "core-js": "3.45.1", | ||||
|     "core-js": "3.46.0", | ||||
|     "cropperjs": "1.6.2", | ||||
|     "culori": "4.0.2", | ||||
|     "date-fns": "4.1.0", | ||||
|     "date-fns-tz": "3.2.0", | ||||
|     "deep-clone-simple": "1.1.1", | ||||
|     "deep-freeze": "0.0.1", | ||||
|     "dialog-polyfill": "0.5.6", | ||||
| @@ -111,10 +111,10 @@ | ||||
|     "fuse.js": "7.1.0", | ||||
|     "google-timezones-json": "1.2.0", | ||||
|     "gulp-zopfli-green": "6.0.2", | ||||
|     "hls.js": "1.6.11", | ||||
|     "hls.js": "1.6.13", | ||||
|     "home-assistant-js-websocket": "9.5.0", | ||||
|     "idb-keyval": "6.2.2", | ||||
|     "intl-messageformat": "10.7.16", | ||||
|     "intl-messageformat": "10.7.18", | ||||
|     "js-yaml": "4.1.0", | ||||
|     "leaflet": "1.9.4", | ||||
|     "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", | ||||
| @@ -122,7 +122,7 @@ | ||||
|     "lit": "3.3.1", | ||||
|     "lit-html": "3.3.1", | ||||
|     "luxon": "3.7.2", | ||||
|     "marked": "16.2.1", | ||||
|     "marked": "16.4.1", | ||||
|     "memoize-one": "6.0.0", | ||||
|     "node-vibrant": "4.0.3", | ||||
|     "object-hash": "3.0.0", | ||||
| @@ -135,7 +135,7 @@ | ||||
|     "stacktrace-js": "2.0.2", | ||||
|     "superstruct": "2.0.2", | ||||
|     "tinykeys": "3.0.0", | ||||
|     "ua-parser-js": "2.0.5", | ||||
|     "ua-parser-js": "2.0.6", | ||||
|     "vue": "2.7.16", | ||||
|     "vue2-daterange-picker": "0.6.8", | ||||
|     "weekstart": "2.0.0", | ||||
| @@ -148,52 +148,52 @@ | ||||
|     "xss": "1.0.15" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "7.28.4", | ||||
|     "@babel/core": "7.28.5", | ||||
|     "@babel/helper-define-polyfill-provider": "0.6.5", | ||||
|     "@babel/plugin-transform-runtime": "7.28.3", | ||||
|     "@babel/preset-env": "7.28.3", | ||||
|     "@bundle-stats/plugin-webpack-filter": "4.21.3", | ||||
|     "@lokalise/node-api": "15.2.1", | ||||
|     "@octokit/auth-oauth-device": "8.0.1", | ||||
|     "@octokit/plugin-retry": "8.0.1", | ||||
|     "@babel/plugin-transform-runtime": "7.28.5", | ||||
|     "@babel/preset-env": "7.28.5", | ||||
|     "@bundle-stats/plugin-webpack-filter": "4.21.5", | ||||
|     "@lokalise/node-api": "15.3.1", | ||||
|     "@octokit/auth-oauth-device": "8.0.2", | ||||
|     "@octokit/plugin-retry": "8.0.2", | ||||
|     "@octokit/rest": "22.0.0", | ||||
|     "@rsdoctor/rspack-plugin": "1.2.3", | ||||
|     "@rspack/core": "1.5.2", | ||||
|     "@rsdoctor/rspack-plugin": "1.3.4", | ||||
|     "@rspack/core": "1.5.8", | ||||
|     "@rspack/dev-server": "1.1.4", | ||||
|     "@types/babel__plugin-transform-runtime": "7.9.5", | ||||
|     "@types/chromecast-caf-receiver": "6.0.24", | ||||
|     "@types/chromecast-caf-receiver": "6.0.22", | ||||
|     "@types/chromecast-caf-sender": "1.0.11", | ||||
|     "@types/color-name": "2.0.0", | ||||
|     "@types/culori": "4.0.1", | ||||
|     "@types/html-minifier-terser": "7.0.2", | ||||
|     "@types/js-yaml": "4.0.9", | ||||
|     "@types/leaflet": "1.9.20", | ||||
|     "@types/leaflet": "1.9.21", | ||||
|     "@types/leaflet-draw": "1.0.13", | ||||
|     "@types/leaflet.markercluster": "1.5.6", | ||||
|     "@types/lodash.merge": "4.6.9", | ||||
|     "@types/luxon": "3.7.1", | ||||
|     "@types/mocha": "10.0.10", | ||||
|     "@types/qrcode": "1.5.5", | ||||
|     "@types/sortablejs": "1.15.8", | ||||
|     "@types/qrcode": "1.5.6", | ||||
|     "@types/sortablejs": "1.15.9", | ||||
|     "@types/tar": "6.1.13", | ||||
|     "@types/ua-parser-js": "0.7.39", | ||||
|     "@types/webspeechapi": "0.0.29", | ||||
|     "@vitest/coverage-v8": "3.2.4", | ||||
|     "@vitest/coverage-v8": "4.0.3", | ||||
|     "babel-loader": "10.0.0", | ||||
|     "babel-plugin-template-html-minifier": "4.1.0", | ||||
|     "browserslist-useragent-regexp": "4.1.3", | ||||
|     "del": "8.0.0", | ||||
|     "eslint": "9.35.0", | ||||
|     "del": "8.0.1", | ||||
|     "eslint": "9.38.0", | ||||
|     "eslint-config-airbnb-base": "15.0.0", | ||||
|     "eslint-config-prettier": "10.1.8", | ||||
|     "eslint-import-resolver-webpack": "0.13.10", | ||||
|     "eslint-plugin-import": "2.32.0", | ||||
|     "eslint-plugin-lit": "2.1.1", | ||||
|     "eslint-plugin-lit-a11y": "5.1.1", | ||||
|     "eslint-plugin-unused-imports": "4.2.0", | ||||
|     "eslint-plugin-wc": "3.0.1", | ||||
|     "eslint-plugin-unused-imports": "4.3.0", | ||||
|     "eslint-plugin-wc": "3.0.2", | ||||
|     "fancy-log": "2.0.0", | ||||
|     "fs-extra": "11.3.1", | ||||
|     "fs-extra": "11.3.2", | ||||
|     "glob": "11.0.3", | ||||
|     "gulp": "5.0.1", | ||||
|     "gulp-brotli": "3.0.0", | ||||
| @@ -201,25 +201,25 @@ | ||||
|     "gulp-rename": "2.1.0", | ||||
|     "html-minifier-terser": "7.2.0", | ||||
|     "husky": "9.1.7", | ||||
|     "jsdom": "26.1.0", | ||||
|     "jsdom": "27.0.1", | ||||
|     "jszip": "3.10.1", | ||||
|     "lint-staged": "16.1.6", | ||||
|     "lint-staged": "16.2.6", | ||||
|     "lit-analyzer": "2.0.3", | ||||
|     "lodash.merge": "4.6.2", | ||||
|     "lodash.template": "4.5.0", | ||||
|     "map-stream": "0.0.7", | ||||
|     "pinst": "3.0.0", | ||||
|     "prettier": "3.6.2", | ||||
|     "rspack-manifest-plugin": "5.0.3", | ||||
|     "rspack-manifest-plugin": "5.1.0", | ||||
|     "serve": "14.2.5", | ||||
|     "sinon": "21.0.0", | ||||
|     "tar": "7.4.3", | ||||
|     "tar": "7.5.1", | ||||
|     "terser-webpack-plugin": "5.3.14", | ||||
|     "ts-lit-plugin": "2.0.2", | ||||
|     "typescript": "5.9.2", | ||||
|     "typescript-eslint": "8.43.0", | ||||
|     "typescript": "5.9.3", | ||||
|     "typescript-eslint": "8.46.2", | ||||
|     "vite-tsconfig-paths": "5.1.4", | ||||
|     "vitest": "3.2.4", | ||||
|     "vitest": "4.0.3", | ||||
|     "webpack-stats-plugin": "1.1.3", | ||||
|     "webpackbar": "7.0.0", | ||||
|     "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" | ||||
| @@ -231,9 +231,9 @@ | ||||
|     "clean-css": "5.3.3", | ||||
|     "@lit/reactive-element": "2.1.1", | ||||
|     "@fullcalendar/daygrid": "6.1.19", | ||||
|     "globals": "16.3.0", | ||||
|     "globals": "16.4.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" | ||||
|   }, | ||||
|   "packageManager": "yarn@4.9.4" | ||||
|   "packageManager": "yarn@4.10.3" | ||||
| } | ||||
|   | ||||
| @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" | ||||
|  | ||||
| [project] | ||||
| name         = "home-assistant-frontend" | ||||
| version      = "20250903.0" | ||||
| version      = "20250924.0" | ||||
| license      = "Apache-2.0" | ||||
| license-files = ["LICENSE*"] | ||||
| description  = "The Home Assistant frontend" | ||||
|   | ||||
| @@ -9,7 +9,7 @@ | ||||
|     ":semanticCommitsDisabled", | ||||
|     "group:monorepos", | ||||
|     "group:recommended", | ||||
|     "npm:unpublishSafe" | ||||
|     "security:minimumReleaseAgeNpm" | ||||
|   ], | ||||
|   "enabledManagers": ["npm", "nvm"], | ||||
|   "postUpdateOptions": ["yarnDedupeHighest"], | ||||
|   | ||||
| @@ -103,7 +103,10 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { | ||||
|           ); | ||||
|           box-shadow: var(--ha-card-box-shadow, none); | ||||
|           box-sizing: border-box; | ||||
|           border-radius: var(--ha-card-border-radius, 12px); | ||||
|           border-radius: var( | ||||
|             --ha-card-border-radius, | ||||
|             var(--ha-border-radius-lg) | ||||
|           ); | ||||
|           border-width: var(--ha-card-border-width, 1px); | ||||
|           border-style: solid; | ||||
|           border-color: var( | ||||
| @@ -132,7 +135,7 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { | ||||
|         } | ||||
|         ha-language-picker { | ||||
|           width: 200px; | ||||
|           border-radius: 4px; | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|           overflow: hidden; | ||||
|           --ha-select-height: 40px; | ||||
|           --mdc-select-fill-color: none; | ||||
|   | ||||
| @@ -1,23 +1,40 @@ | ||||
| import { formatHex, parse } from "culori"; | ||||
|  | ||||
| /** | ||||
|  * Expands a 3-digit hex color to a 6-digit hex color. | ||||
|  * @param hex - The hex color to expand. | ||||
|  * @returns The expanded hex color. | ||||
|  * @throws If the hex color is invalid. | ||||
|  */ | ||||
| export const expandHex = (hex: string): string => { | ||||
|   hex = hex.replace("#", ""); | ||||
|   if (hex.length === 6) return hex; | ||||
|   let result = ""; | ||||
|   for (const val of hex) { | ||||
|     result += val + val; | ||||
|   const color = parse(hex); | ||||
|   if (!color) { | ||||
|     throw new Error(`Invalid hex color: ${hex}`); | ||||
|   } | ||||
|   return result; | ||||
|   const formattedColor = formatHex(color); | ||||
|   if (!formattedColor) { | ||||
|     throw new Error(`Could not format hex color: ${hex}`); | ||||
|   } | ||||
|   return formattedColor.replace("#", ""); | ||||
| }; | ||||
|  | ||||
| // Blend 2 hex colors: c1 is placed over c2, blend is c1's opacity. | ||||
| /** | ||||
|  * Blends two hex colors. c1 is placed over c2, blend is c1's opacity. | ||||
|  * @param c1 - The first hex color. | ||||
|  * @param c2 - The second hex color. | ||||
|  * @param blend - The blend percentage (0-100). | ||||
|  * @returns The blended hex color. | ||||
|  */ | ||||
| export const hexBlend = (c1: string, c2: string, blend = 50): string => { | ||||
|   let color = ""; | ||||
|   c1 = expandHex(c1); | ||||
|   c2 = expandHex(c2); | ||||
|   let color = ""; | ||||
|   for (let i = 0; i <= 5; i += 2) { | ||||
|     const h1 = parseInt(c1.substring(i, i + 2), 16); | ||||
|     const h2 = parseInt(c2.substring(i, i + 2), 16); | ||||
|     let hex = Math.floor(h2 + (h1 - h2) * (blend / 100)).toString(16); | ||||
|     while (hex.length < 2) hex = "0" + hex; | ||||
|     const hex = Math.floor(h2 + (h1 - h2) * (blend / 100)) | ||||
|       .toString(16) | ||||
|       .padStart(2, "0"); | ||||
|     color += hex; | ||||
|   } | ||||
|   return `#${color}`; | ||||
|   | ||||
| @@ -1,28 +1,49 @@ | ||||
| export const luminosity = (rgb: [number, number, number]): number => { | ||||
|   // http://www.w3.org/TR/WCAG20/#relativeluminancedef | ||||
|   const lum: [number, number, number] = [0, 0, 0]; | ||||
|   for (let i = 0; i < rgb.length; i++) { | ||||
|     const chan = rgb[i] / 255; | ||||
|     lum[i] = chan <= 0.03928 ? chan / 12.92 : ((chan + 0.055) / 1.055) ** 2.4; | ||||
|   } | ||||
| import { wcagLuminance, wcagContrast } from "culori"; | ||||
|  | ||||
|   return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2]; | ||||
| }; | ||||
| /** | ||||
|  * Calculates the luminosity of an RGB color. | ||||
|  * @param rgb - The RGB color to calculate the luminosity of. | ||||
|  * @returns The luminosity of the color. | ||||
|  */ | ||||
| export const luminosity = (rgb: [number, number, number]): number => | ||||
|   wcagLuminance({ | ||||
|     mode: "rgb", | ||||
|     r: rgb[0] / 255, | ||||
|     g: rgb[1] / 255, | ||||
|     b: rgb[2] / 255, | ||||
|   }); | ||||
|  | ||||
| /** | ||||
|  * Calculates the contrast ratio between two RGB colors. | ||||
|  * @param color1 - The first color to calculate the contrast ratio of. | ||||
|  * @param color2 - The second color to calculate the contrast ratio of. | ||||
|  * @returns The contrast ratio between the two colors. | ||||
|  */ | ||||
| export const rgbContrast = ( | ||||
|   color1: [number, number, number], | ||||
|   color2: [number, number, number] | ||||
| ) => { | ||||
|   const lum1 = luminosity(color1); | ||||
|   const lum2 = luminosity(color2); | ||||
|  | ||||
|   if (lum1 > lum2) { | ||||
|     return (lum1 + 0.05) / (lum2 + 0.05); | ||||
|   } | ||||
|  | ||||
|   return (lum2 + 0.05) / (lum1 + 0.05); | ||||
| }; | ||||
| ) => | ||||
|   wcagContrast( | ||||
|     { | ||||
|       mode: "rgb", | ||||
|       r: color1[0] / 255, | ||||
|       g: color1[1] / 255, | ||||
|       b: color1[2] / 255, | ||||
|     }, | ||||
|     { | ||||
|       mode: "rgb", | ||||
|       r: color2[0] / 255, | ||||
|       g: color2[1] / 255, | ||||
|       b: color2[2] / 255, | ||||
|     } | ||||
|   ); | ||||
|  | ||||
| /** | ||||
|  * Calculates the contrast ratio between two RGB colors. | ||||
|  * @param rgb1 - The first color to calculate the contrast ratio of. | ||||
|  * @param rgb2 - The second color to calculate the contrast ratio of. | ||||
|  * @returns The contrast ratio between the two colors. | ||||
|  */ | ||||
| export const getRGBContrastRatio = ( | ||||
|   rgb1: [number, number, number], | ||||
|   rgb2: [number, number, number] | ||||
|   | ||||
							
								
								
									
										141
									
								
								src/common/controllers/undo-redo-controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/common/controllers/undo-redo-controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| import type { | ||||
|   ReactiveController, | ||||
|   ReactiveControllerHost, | ||||
| } from "@lit/reactive-element/reactive-controller"; | ||||
|  | ||||
| const UNDO_REDO_STACK_LIMIT = 75; | ||||
|  | ||||
| /** | ||||
|  * Configuration options for the UndoRedoController. | ||||
|  * | ||||
|  * @template ConfigType The type of configuration to manage. | ||||
|  */ | ||||
| export interface UndoRedoControllerConfig<ConfigType> { | ||||
|   stackLimit?: number; | ||||
|   currentConfig: () => ConfigType; | ||||
|   apply: (config: ConfigType) => void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * A controller to manage undo and redo operations for a given configuration type. | ||||
|  * | ||||
|  * @template ConfigType The type of configuration to manage. | ||||
|  */ | ||||
| export class UndoRedoController<ConfigType> implements ReactiveController { | ||||
|   private _host: ReactiveControllerHost; | ||||
|  | ||||
|   private _undoStack: ConfigType[] = []; | ||||
|  | ||||
|   private _redoStack: ConfigType[] = []; | ||||
|  | ||||
|   private readonly _stackLimit: number = UNDO_REDO_STACK_LIMIT; | ||||
|  | ||||
|   private readonly _apply: (config: ConfigType) => void = () => { | ||||
|     throw new Error("No apply function provided"); | ||||
|   }; | ||||
|  | ||||
|   private readonly _currentConfig: () => ConfigType = () => { | ||||
|     throw new Error("No currentConfig function provided"); | ||||
|   }; | ||||
|  | ||||
|   constructor( | ||||
|     host: ReactiveControllerHost, | ||||
|     options: UndoRedoControllerConfig<ConfigType> | ||||
|   ) { | ||||
|     if (options.stackLimit !== undefined) { | ||||
|       this._stackLimit = options.stackLimit; | ||||
|     } | ||||
|  | ||||
|     this._apply = options.apply; | ||||
|     this._currentConfig = options.currentConfig; | ||||
|     this._host = host; | ||||
|     host.addController(this); | ||||
|   } | ||||
|  | ||||
|   hostConnected() { | ||||
|     window.addEventListener("undo-change", this._onUndoChange); | ||||
|   } | ||||
|  | ||||
|   hostDisconnected() { | ||||
|     window.removeEventListener("undo-change", this._onUndoChange); | ||||
|   } | ||||
|  | ||||
|   private _onUndoChange = (ev: Event) => { | ||||
|     ev.stopPropagation(); | ||||
|     this.undo(); | ||||
|     this._host.requestUpdate(); | ||||
|   }; | ||||
|  | ||||
|   /** | ||||
|    * Indicates whether there are actions available to undo. | ||||
|    * | ||||
|    * @returns `true` if there are actions to undo, `false` otherwise. | ||||
|    */ | ||||
|   public get canUndo(): boolean { | ||||
|     return this._undoStack.length > 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Indicates whether there are actions available to redo. | ||||
|    * | ||||
|    * @returns `true` if there are actions to redo, `false` otherwise. | ||||
|    */ | ||||
|   public get canRedo(): boolean { | ||||
|     return this._redoStack.length > 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Commits the current configuration to the undo stack and clears the redo stack. | ||||
|    * | ||||
|    * @param config The current configuration to commit. | ||||
|    */ | ||||
|   public commit(config: ConfigType) { | ||||
|     if (this._undoStack.length >= this._stackLimit) { | ||||
|       this._undoStack.shift(); | ||||
|     } | ||||
|     this._undoStack.push({ ...config }); | ||||
|     this._redoStack = []; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Undoes the last action and applies the previous configuration | ||||
|    * while saving the current configuration to the redo stack. | ||||
|    */ | ||||
|   public undo() { | ||||
|     if (this._undoStack.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|     this._redoStack.push({ ...this._currentConfig() }); | ||||
|     const config = this._undoStack.pop()!; | ||||
|     this._apply(config); | ||||
|     this._host.requestUpdate(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Redoes the last undone action and reapplies the configuration | ||||
|    * while saving the current configuration to the undo stack. | ||||
|    */ | ||||
|   public redo() { | ||||
|     if (this._redoStack.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|     this._undoStack.push({ ...this._currentConfig() }); | ||||
|     const config = this._redoStack.pop()!; | ||||
|     this._apply(config); | ||||
|     this._host.requestUpdate(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Resets the undo and redo stacks, clearing all history. | ||||
|    */ | ||||
|   public reset() { | ||||
|     this._undoStack = []; | ||||
|     this._redoStack = []; | ||||
|   } | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HASSDomEvents { | ||||
|     "undo-change": undefined; | ||||
|   } | ||||
| } | ||||
| @@ -11,7 +11,7 @@ import { | ||||
|   differenceInDays, | ||||
|   addDays, | ||||
| } from "date-fns"; | ||||
| import { toZonedTime, fromZonedTime } from "date-fns-tz"; | ||||
| import { TZDate } from "@date-fns/tz"; | ||||
| import type { HassConfig } from "home-assistant-js-websocket"; | ||||
| import type { FrontendLocaleData } from "../../data/translation"; | ||||
| import { TimeZone } from "../../data/translation"; | ||||
| @@ -22,12 +22,13 @@ const calcZonedDate = ( | ||||
|   fn: (date: Date, options?: any) => Date | number | boolean, | ||||
|   options? | ||||
| ) => { | ||||
|   const inputZoned = toZonedTime(date, tz); | ||||
|   const fnZoned = fn(inputZoned, options); | ||||
|   if (fnZoned instanceof Date) { | ||||
|     return fromZonedTime(fnZoned, tz) as Date; | ||||
|   const tzDate = new TZDate(date, tz); | ||||
|   const fnResult = fn(tzDate, options); | ||||
|   if (fnResult instanceof Date) { | ||||
|     // Convert back to regular Date in the specified timezone | ||||
|     return new Date(fnResult.getTime()); | ||||
|   } | ||||
|   return fnZoned; | ||||
|   return fnResult; | ||||
| }; | ||||
|  | ||||
| export const calcDate = ( | ||||
| @@ -65,7 +66,7 @@ export const calcDateDifferenceProperty = ( | ||||
|     locale, | ||||
|     config, | ||||
|     locale.time_zone === TimeZone.server | ||||
|       ? toZonedTime(startDate, config.time_zone) | ||||
|       ? new TZDate(startDate, config.time_zone) | ||||
|       : startDate | ||||
|   ); | ||||
|  | ||||
| @@ -144,3 +145,36 @@ export const shiftDateRange = ( | ||||
|   } | ||||
|   return { start, end }; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * @description Parses a date in browser display timezone | ||||
|  * @param date - The date to parse | ||||
|  * @param timezone - The timezone to parse the date in | ||||
|  * @returns The parsed date as a Date object | ||||
|  */ | ||||
| export const parseDate = (date: string, timezone: string): Date => { | ||||
|   const tzDate = new TZDate(date, timezone); | ||||
|   return new Date(tzDate.getTime()); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * @description Formats a date in browser display timezone | ||||
|  * @param date - The date to format | ||||
|  * @param timezone - The timezone to format the date in | ||||
|  * @returns The formatted date in YYYY-MM-DD format | ||||
|  */ | ||||
| export const formatDate = (date: Date, timezone: string): string => { | ||||
|   const tzDate = new TZDate(date, timezone); | ||||
|   return tzDate.toISOString().split("T")[0]; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * @description Formats a time in browser display timezone | ||||
|  * @param date - The date to format | ||||
|  * @param timezone - The timezone to format the time in | ||||
|  * @returns The formatted time in HH:mm:ss format | ||||
|  */ | ||||
| export const formatTime = (date: Date, timezone: string): string => { | ||||
|   const tzDate = new TZDate(date, timezone); | ||||
|   return tzDate.toISOString().split("T")[1].split(".")[0]; | ||||
| }; | ||||
|   | ||||
| @@ -31,10 +31,10 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => { | ||||
|  | ||||
|   const location = window.location; | ||||
|   const origin = location.origin || location.protocol + "//" + location.host; | ||||
|   if (href.indexOf(origin) !== 0) { | ||||
|   if (!href.startsWith(origin)) { | ||||
|     return undefined; | ||||
|   } | ||||
|   href = href.substr(origin.length); | ||||
|   href = href.slice(origin.length); | ||||
|  | ||||
|   if (href === "#") { | ||||
|     return undefined; | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| export const batteryStateColor = (state: string): string | undefined => { | ||||
| export const batteryStateColorProperty = ( | ||||
|   state: string | ||||
| ): string | undefined => { | ||||
|   const value = Number(state); | ||||
|   if (isNaN(value)) { | ||||
|     return undefined; | ||||
|   } | ||||
|   if (value >= 70) { | ||||
|     return "var(--state-sensor-battery-high-color)"; | ||||
|     return "--state-sensor-battery-high-color"; | ||||
|   } | ||||
|   if (value >= 30) { | ||||
|     return "var(--state-sensor-battery-medium-color)"; | ||||
|     return "--state-sensor-battery-medium-color"; | ||||
|   } | ||||
|   return "var(--state-sensor-battery-low-color)"; | ||||
|   return "--state-sensor-battery-low-color"; | ||||
| }; | ||||
|   | ||||
| @@ -10,9 +10,10 @@ import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name"; | ||||
|  | ||||
| export const computeEntityName = ( | ||||
|   stateObj: HassEntity, | ||||
|   hass: HomeAssistant | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"] | ||||
| ): string | undefined => { | ||||
|   const entry = hass.entities[stateObj.entity_id] as | ||||
|   const entry = entities[stateObj.entity_id] as | ||||
|     | EntityRegistryDisplayEntry | ||||
|     | undefined; | ||||
|  | ||||
| @@ -20,12 +21,13 @@ export const computeEntityName = ( | ||||
|     // Fall back to state name if not in the entity registry (friendly name) | ||||
|     return computeStateName(stateObj); | ||||
|   } | ||||
|   return computeEntityEntryName(entry, hass); | ||||
|   return computeEntityEntryName(entry, devices); | ||||
| }; | ||||
|  | ||||
| export const computeEntityEntryName = ( | ||||
|   entry: EntityRegistryDisplayEntry | EntityRegistryEntry, | ||||
|   hass: HomeAssistant | ||||
|   devices: HomeAssistant["devices"], | ||||
|   fallbackStateObj?: HassEntity | ||||
| ): string | undefined => { | ||||
|   const name = | ||||
|     entry.name || | ||||
| @@ -33,15 +35,14 @@ export const computeEntityEntryName = ( | ||||
|       ? String(entry.original_name) | ||||
|       : undefined); | ||||
|  | ||||
|   const device = entry.device_id ? hass.devices[entry.device_id] : undefined; | ||||
|   const device = entry.device_id ? devices[entry.device_id] : undefined; | ||||
|  | ||||
|   if (!device) { | ||||
|     if (name) { | ||||
|       return name; | ||||
|     } | ||||
|     const stateObj = hass.states[entry.entity_id] as HassEntity | undefined; | ||||
|     if (stateObj) { | ||||
|       return computeStateName(stateObj); | ||||
|     if (fallbackStateObj) { | ||||
|       return computeStateName(fallbackStateObj); | ||||
|     } | ||||
|     return undefined; | ||||
|   } | ||||
| @@ -60,3 +61,9 @@ export const computeEntityEntryName = ( | ||||
|  | ||||
|   return name; | ||||
| }; | ||||
|  | ||||
| export const entityUseDeviceName = ( | ||||
|   stateObj: HassEntity, | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"] | ||||
| ): boolean => !computeEntityName(stateObj, entities, devices); | ||||
|   | ||||
							
								
								
									
										109
									
								
								src/common/entity/compute_entity_name_display.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/common/entity/compute_entity_name_display.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { ensureArray } from "../array/ensure-array"; | ||||
| 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 { getEntityContext } from "./context/get_entity_context"; | ||||
|  | ||||
| const DEFAULT_SEPARATOR = " "; | ||||
|  | ||||
| export const DEFAULT_ENTITY_NAME = [ | ||||
|   { type: "device" }, | ||||
|   { type: "entity" }, | ||||
| ] satisfies EntityNameItem[]; | ||||
|  | ||||
| export type EntityNameItem = | ||||
|   | { | ||||
|       type: "entity" | "device" | "area" | "floor"; | ||||
|     } | ||||
|   | { | ||||
|       type: "text"; | ||||
|       text: string; | ||||
|     }; | ||||
|  | ||||
| export interface EntityNameOptions { | ||||
|   separator?: string; | ||||
| } | ||||
|  | ||||
| export const computeEntityNameDisplay = ( | ||||
|   stateObj: HassEntity, | ||||
|   name: EntityNameItem | EntityNameItem[] | undefined, | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"], | ||||
|   areas: HomeAssistant["areas"], | ||||
|   floors: HomeAssistant["floors"], | ||||
|   options?: EntityNameOptions | ||||
| ) => { | ||||
|   let items = ensureArray(name || DEFAULT_ENTITY_NAME); | ||||
|  | ||||
|   const separator = options?.separator ?? DEFAULT_SEPARATOR; | ||||
|  | ||||
|   // If all items are text, just join them | ||||
|   if (items.every((n) => n.type === "text")) { | ||||
|     return items.map((item) => item.text).join(separator); | ||||
|   } | ||||
|  | ||||
|   const useDeviceName = entityUseDeviceName(stateObj, entities, devices); | ||||
|  | ||||
|   // If entity uses device name, and device is not already included, replace it with device name | ||||
|   if (useDeviceName) { | ||||
|     const hasDevice = items.some((n) => n.type === "device"); | ||||
|     if (!hasDevice) { | ||||
|       items = items.map((n) => (n.type === "entity" ? { type: "device" } : n)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const names = computeEntityNameList( | ||||
|     stateObj, | ||||
|     items, | ||||
|     entities, | ||||
|     devices, | ||||
|     areas, | ||||
|     floors | ||||
|   ); | ||||
|  | ||||
|   // If after processing there is only one name, return that | ||||
|   if (names.length === 1) { | ||||
|     return names[0] || ""; | ||||
|   } | ||||
|  | ||||
|   return names.filter((n) => n).join(separator); | ||||
| }; | ||||
|  | ||||
| export const computeEntityNameList = ( | ||||
|   stateObj: HassEntity, | ||||
|   name: EntityNameItem[], | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"], | ||||
|   areas: HomeAssistant["areas"], | ||||
|   floors: HomeAssistant["floors"] | ||||
| ): (string | undefined)[] => { | ||||
|   const { device, area, floor } = getEntityContext( | ||||
|     stateObj, | ||||
|     entities, | ||||
|     devices, | ||||
|     areas, | ||||
|     floors | ||||
|   ); | ||||
|  | ||||
|   const names = name.map((item) => { | ||||
|     switch (item.type) { | ||||
|       case "entity": | ||||
|         return computeEntityName(stateObj, entities, devices); | ||||
|       case "device": | ||||
|         return device ? computeDeviceName(device) : undefined; | ||||
|       case "area": | ||||
|         return area ? computeAreaName(area) : undefined; | ||||
|       case "floor": | ||||
|         return floor ? computeFloorName(floor) : undefined; | ||||
|       case "text": | ||||
|         return item.text; | ||||
|       default: | ||||
|         return ""; | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   return names; | ||||
| }; | ||||
| @@ -1,3 +1,3 @@ | ||||
| /** Compute the object ID of a state. */ | ||||
| export const computeObjectId = (entityId: string): string => | ||||
|   entityId.substr(entityId.indexOf(".") + 1); | ||||
|   entityId.slice(entityId.indexOf(".") + 1); | ||||
|   | ||||
| @@ -8,10 +8,10 @@ interface AreaContext { | ||||
| } | ||||
| export const getAreaContext = ( | ||||
|   area: AreaRegistryEntry, | ||||
|   hass: HomeAssistant | ||||
|   hassFloors: HomeAssistant["floors"] | ||||
| ): AreaContext => { | ||||
|   const floorId = area.floor_id; | ||||
|   const floor = floorId ? hass.floors[floorId] : undefined; | ||||
|   const floor = floorId ? hassFloors[floorId] : undefined; | ||||
|  | ||||
|   return { | ||||
|     area: area, | ||||
|   | ||||
| @@ -18,9 +18,12 @@ interface EntityContext { | ||||
|  | ||||
| export const getEntityContext = ( | ||||
|   stateObj: HassEntity, | ||||
|   hass: HomeAssistant | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"], | ||||
|   areas: HomeAssistant["areas"], | ||||
|   floors: HomeAssistant["floors"] | ||||
| ): EntityContext => { | ||||
|   const entry = hass.entities[stateObj.entity_id] as | ||||
|   const entry = entities[stateObj.entity_id] as | ||||
|     | EntityRegistryDisplayEntry | ||||
|     | undefined; | ||||
|  | ||||
| @@ -32,7 +35,7 @@ export const getEntityContext = ( | ||||
|       floor: null, | ||||
|     }; | ||||
|   } | ||||
|   return getEntityEntryContext(entry, hass); | ||||
|   return getEntityEntryContext(entry, entities, devices, areas, floors); | ||||
| }; | ||||
|  | ||||
| export const getEntityEntryContext = ( | ||||
| @@ -40,15 +43,18 @@ export const getEntityEntryContext = ( | ||||
|     | EntityRegistryDisplayEntry | ||||
|     | EntityRegistryEntry | ||||
|     | ExtEntityRegistryEntry, | ||||
|   hass: HomeAssistant | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"], | ||||
|   areas: HomeAssistant["areas"], | ||||
|   floors: HomeAssistant["floors"] | ||||
| ): EntityContext => { | ||||
|   const entity = hass.entities[entry.entity_id]; | ||||
|   const entity = entities[entry.entity_id]; | ||||
|   const deviceId = entry?.device_id; | ||||
|   const device = deviceId ? hass.devices[deviceId] : undefined; | ||||
|   const device = deviceId ? devices[deviceId] : undefined; | ||||
|   const areaId = entry?.area_id || device?.area_id; | ||||
|   const area = areaId ? hass.areas[areaId] : undefined; | ||||
|   const area = areaId ? areas[areaId] : undefined; | ||||
|   const floorId = area?.floor_id; | ||||
|   const floor = floorId ? hass.floors[floorId] : undefined; | ||||
|   const floor = floorId ? floors[floorId] : undefined; | ||||
|  | ||||
|   return { | ||||
|     entity: entity, | ||||
|   | ||||
| @@ -60,7 +60,13 @@ export const generateEntityFilter = ( | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const { area, floor, device, entity } = getEntityContext(stateObj, hass); | ||||
|     const { area, floor, device, entity } = getEntityContext( | ||||
|       stateObj, | ||||
|       hass.entities, | ||||
|       hass.devices, | ||||
|       hass.areas, | ||||
|       hass.floors | ||||
|     ); | ||||
|  | ||||
|     if (entity && entity.hidden) { | ||||
|       return false; | ||||
| @@ -116,3 +122,22 @@ export const generateEntityFilter = ( | ||||
|     return true; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const findEntities = ( | ||||
|   entities: string[], | ||||
|   filters: EntityFilterFunc[] | ||||
| ): string[] => { | ||||
|   const seen = new Set<string>(); | ||||
|   const results: string[] = []; | ||||
|  | ||||
|   for (const filter of filters) { | ||||
|     for (const entity of entities) { | ||||
|       if (filter(entity) && !seen.has(entity)) { | ||||
|         seen.add(entity); | ||||
|         results.push(entity); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return results; | ||||
| }; | ||||
|   | ||||
| @@ -18,6 +18,7 @@ export const FIXED_DOMAIN_STATES = { | ||||
|     "pending", | ||||
|     "triggered", | ||||
|   ], | ||||
|   alert: ["on", "off", "idle"], | ||||
|   assist_satellite: ["idle", "listening", "responding", "processing"], | ||||
|   automation: ["on", "off"], | ||||
|   binary_sensor: ["on", "off"], | ||||
|   | ||||
| @@ -5,15 +5,12 @@ import { computeDomain } from "./compute_domain"; | ||||
| export function stateActive(stateObj: HassEntity, state?: string): boolean { | ||||
|   const domain = computeDomain(stateObj.entity_id); | ||||
|   const compareState = state !== undefined ? state : stateObj?.state; | ||||
|   return domainStateActive(domain, compareState); | ||||
| } | ||||
|  | ||||
| export function domainStateActive(domain: string, state: string) { | ||||
|   if (["button", "event", "input_button", "scene"].includes(domain)) { | ||||
|     return state !== UNAVAILABLE; | ||||
|     return compareState !== UNAVAILABLE; | ||||
|   } | ||||
|  | ||||
|   if (isUnavailableState(state)) { | ||||
|   if (isUnavailableState(compareState)) { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
| @@ -21,40 +18,40 @@ export function domainStateActive(domain: string, state: string) { | ||||
|   // such as "alert" where "off" is still a somewhat active state and | ||||
|   // therefore gets a custom color and "idle" is instead the state that | ||||
|   // matches what most other domains consider inactive. | ||||
|   if (state === OFF && domain !== "alert") { | ||||
|   if (compareState === OFF && domain !== "alert") { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   // Custom cases | ||||
|   switch (domain) { | ||||
|     case "alarm_control_panel": | ||||
|       return state !== "disarmed"; | ||||
|       return compareState !== "disarmed"; | ||||
|     case "alert": | ||||
|       // "on" and "off" are active, as "off" just means alert was acknowledged but is still active | ||||
|       return state !== "idle"; | ||||
|       return compareState !== "idle"; | ||||
|     case "cover": | ||||
|       return state !== "closed"; | ||||
|       return compareState !== "closed"; | ||||
|     case "device_tracker": | ||||
|     case "person": | ||||
|       return state !== "not_home"; | ||||
|       return compareState !== "not_home"; | ||||
|     case "lawn_mower": | ||||
|       return ["mowing", "error"].includes(state); | ||||
|       return ["mowing", "error"].includes(compareState); | ||||
|     case "lock": | ||||
|       return state !== "locked"; | ||||
|       return compareState !== "locked"; | ||||
|     case "media_player": | ||||
|       return state !== "standby"; | ||||
|       return compareState !== "standby"; | ||||
|     case "vacuum": | ||||
|       return !["idle", "docked", "paused"].includes(state); | ||||
|       return !["idle", "docked", "paused"].includes(compareState); | ||||
|     case "valve": | ||||
|       return state !== "closed"; | ||||
|       return compareState !== "closed"; | ||||
|     case "plant": | ||||
|       return state === "problem"; | ||||
|       return compareState === "problem"; | ||||
|     case "group": | ||||
|       return ["on", "home", "open", "locked", "problem"].includes(state); | ||||
|       return ["on", "home", "open", "locked", "problem"].includes(compareState); | ||||
|     case "timer": | ||||
|       return state === "active"; | ||||
|       return compareState === "active"; | ||||
|     case "camera": | ||||
|       return state === "streaming"; | ||||
|       return compareState === "streaming"; | ||||
|   } | ||||
|  | ||||
|   return true; | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| /** Return a color representing a state. */ | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import { UNAVAILABLE } from "../../data/entity"; | ||||
| import type { GroupEntity } from "../../data/group"; | ||||
| import { computeGroupDomain } from "../../data/group"; | ||||
| import { computeCssVariable } from "../../resources/css-variables"; | ||||
| import { slugify } from "../string/slugify"; | ||||
| import { batteryStateColor } from "./color/battery_color"; | ||||
| import { batteryStateColorProperty } from "./color/battery_color"; | ||||
| import { computeDomain } from "./compute_domain"; | ||||
| import { domainStateActive } from "./state_active"; | ||||
| import { stateActive } from "./state_active"; | ||||
|  | ||||
| const STATE_COLORED_DOMAIN = new Set([ | ||||
|   "alarm_control_panel", | ||||
| @@ -38,22 +40,76 @@ const STATE_COLORED_DOMAIN = new Set([ | ||||
|   "vacuum", | ||||
|   "valve", | ||||
|   "water_heater", | ||||
|   "weather", | ||||
| ]); | ||||
|  | ||||
| export const stateColor = ( | ||||
|   element: HTMLElement | CSSStyleDeclaration, | ||||
| export const stateColorCss = (stateObj: HassEntity, state?: string) => { | ||||
|   const compareState = state !== undefined ? state : stateObj?.state; | ||||
|   if (compareState === UNAVAILABLE) { | ||||
|     return `var(--state-unavailable-color)`; | ||||
|   } | ||||
|  | ||||
|   const properties = stateColorProperties(stateObj, state); | ||||
|   if (properties) { | ||||
|     return computeCssVariable(properties); | ||||
|   } | ||||
|  | ||||
|   return undefined; | ||||
| }; | ||||
|  | ||||
| export const domainStateColorProperties = ( | ||||
|   domain: string, | ||||
|   stateObj: HassEntity, | ||||
|   state?: string | ||||
| ): string[] => { | ||||
|   const compareState = state !== undefined ? state : stateObj.state; | ||||
|   const active = stateActive(stateObj, state); | ||||
|  | ||||
|   return domainColorProperties( | ||||
|     domain, | ||||
|     stateObj.attributes.device_class, | ||||
|     compareState, | ||||
|     active | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const domainColorProperties = ( | ||||
|   domain: string, | ||||
|   deviceClass: string | undefined, | ||||
|   state: string, | ||||
|   active: boolean | ||||
| ) => { | ||||
|   const properties: string[] = []; | ||||
|  | ||||
|   const stateKey = slugify(state, "_"); | ||||
|   const activeKey = active ? "active" : "inactive"; | ||||
|  | ||||
|   if (deviceClass) { | ||||
|     properties.push(`--state-${domain}-${deviceClass}-${stateKey}-color`); | ||||
|   } | ||||
|  | ||||
|   properties.push( | ||||
|     `--state-${domain}-${stateKey}-color`, | ||||
|     `--state-${domain}-${activeKey}-color`, | ||||
|     `--state-${activeKey}-color` | ||||
|   ); | ||||
|  | ||||
|   return properties; | ||||
| }; | ||||
|  | ||||
| export const stateColorProperties = ( | ||||
|   stateObj: HassEntity, | ||||
|   state?: string | ||||
| ): string[] | undefined => { | ||||
|   const compareState = state !== undefined ? state : stateObj?.state; | ||||
|   const domain = computeDomain(stateObj.entity_id); | ||||
|   const dc = stateObj.attributes.device_class; | ||||
|   const compareState = state !== undefined ? state : stateObj.state; | ||||
|  | ||||
|   // Special rules for battery coloring | ||||
|   if (domain === "sensor" && dc === "battery") { | ||||
|     const property = batteryStateColor(compareState); | ||||
|     const property = batteryStateColorProperty(compareState); | ||||
|     if (property) { | ||||
|       return property; | ||||
|       return [property]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -61,52 +117,17 @@ export const stateColor = ( | ||||
|   if (domain === "group") { | ||||
|     const groupDomain = computeGroupDomain(stateObj as GroupEntity); | ||||
|     if (groupDomain && STATE_COLORED_DOMAIN.has(groupDomain)) { | ||||
|       return domainStateColor(element, groupDomain, undefined, compareState); | ||||
|       return domainStateColorProperties(groupDomain, stateObj, state); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (STATE_COLORED_DOMAIN.has(domain)) { | ||||
|     return domainStateColor(element, domain, dc, compareState); | ||||
|     return domainStateColorProperties(domain, stateObj, state); | ||||
|   } | ||||
|  | ||||
|   return undefined; | ||||
| }; | ||||
|  | ||||
| export const domainStateColor = ( | ||||
|   element: HTMLElement | CSSStyleDeclaration, | ||||
|   domain: string, | ||||
|   deviceClass: string | undefined, | ||||
|   state: string | ||||
| ) => { | ||||
|   const style = | ||||
|     element instanceof CSSStyleDeclaration | ||||
|       ? element | ||||
|       : getComputedStyle(element); | ||||
|  | ||||
|   const stateKey = slugify(state, "_"); | ||||
|  | ||||
|   const active = domainStateActive(domain, state); | ||||
|   const activeKey = active ? "active" : "inactive"; | ||||
|  | ||||
|   const variables = [ | ||||
|     `--state-${domain}-${stateKey}-color`, | ||||
|     `--state-${domain}-${activeKey}-color`, | ||||
|     `--state-${activeKey}-color`, | ||||
|   ]; | ||||
|  | ||||
|   if (deviceClass) { | ||||
|     variables.unshift(`--state-${domain}-${deviceClass}-${stateKey}-color`); | ||||
|   } | ||||
|  | ||||
|   for (const variable of variables) { | ||||
|     const value = style.getPropertyValue(variable).trim(); | ||||
|     if (value) { | ||||
|       return value; | ||||
|     } | ||||
|   } | ||||
|   return undefined; | ||||
| }; | ||||
|  | ||||
| export const stateColorBrightness = (stateObj: HassEntity): string => { | ||||
|   if ( | ||||
|     stateObj.attributes.brightness && | ||||
|   | ||||
| @@ -32,6 +32,8 @@ export const numberFormatToLocale = ( | ||||
|       return ["de", "es", "it"]; // Use German with fallback to Spanish then Italian formatting 1.234.567,89 | ||||
|     case NumberFormat.space_comma: | ||||
|       return ["fr", "sv", "cs"]; // Use French with fallback to Swedish and Czech formatting 1 234 567,89 | ||||
|     case NumberFormat.quote_decimal: | ||||
|       return ["de-CH"]; // Use German (Switzerland) formatting 1'234'567.89 | ||||
|     case NumberFormat.system: | ||||
|       return undefined; | ||||
|     default: | ||||
|   | ||||
| @@ -67,10 +67,7 @@ function isSeparatorAtPos(value: string, index: number): boolean { | ||||
|     case undefined: | ||||
|       return false; | ||||
|     default: | ||||
|       if (isEmojiImprecise(code)) { | ||||
|         return true; | ||||
|       } | ||||
|       return false; | ||||
|       return isEmojiImprecise(code); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; | ||||
| import type { FrontendLocaleData } from "../../data/translation"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { | ||||
|   computeEntityNameDisplay, | ||||
|   type EntityNameItem, | ||||
|   type EntityNameOptions, | ||||
| } from "../entity/compute_entity_name_display"; | ||||
| import type { LocalizeFunc } from "./localize"; | ||||
|  | ||||
| export type FormatEntityStateFunc = ( | ||||
| @@ -17,16 +22,28 @@ export type FormatEntityAttributeNameFunc = ( | ||||
|   attribute: string | ||||
| ) => string; | ||||
|  | ||||
| export type EntityNameType = "entity" | "device" | "area" | "floor"; | ||||
|  | ||||
| export type FormatEntityNameFunc = ( | ||||
|   stateObj: HassEntity, | ||||
|   name: EntityNameItem | EntityNameItem[], | ||||
|   options?: EntityNameOptions | ||||
| ) => string; | ||||
|  | ||||
| export const computeFormatFunctions = async ( | ||||
|   localize: LocalizeFunc, | ||||
|   locale: FrontendLocaleData, | ||||
|   config: HassConfig, | ||||
|   entities: HomeAssistant["entities"], | ||||
|   devices: HomeAssistant["devices"], | ||||
|   areas: HomeAssistant["areas"], | ||||
|   floors: HomeAssistant["floors"], | ||||
|   sensorNumericDeviceClasses: string[] | ||||
| ): Promise<{ | ||||
|   formatEntityState: FormatEntityStateFunc; | ||||
|   formatEntityAttributeValue: FormatEntityAttributeValueFunc; | ||||
|   formatEntityAttributeName: FormatEntityAttributeNameFunc; | ||||
|   formatEntityName: FormatEntityNameFunc; | ||||
| }> => { | ||||
|   const { computeStateDisplay } = await import( | ||||
|     "../entity/compute_state_display" | ||||
| @@ -57,5 +74,15 @@ export const computeFormatFunctions = async ( | ||||
|       ), | ||||
|     formatEntityAttributeName: (stateObj, attribute) => | ||||
|       computeAttributeNameDisplay(localize, stateObj, entities, attribute), | ||||
|     formatEntityName: (stateObj, name, options) => | ||||
|       computeEntityNameDisplay( | ||||
|         stateObj, | ||||
|         name, | ||||
|         entities, | ||||
|         devices, | ||||
|         areas, | ||||
|         floors, | ||||
|         options | ||||
|       ), | ||||
|   }; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										18
									
								
								src/common/util/order-properties.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/common/util/order-properties.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| /** | ||||
|  * Orders object properties according to a specified key order. | ||||
|  * Properties not in the order array will be placed at the end. | ||||
|  */ | ||||
| export function orderProperties<T extends Record<string, any>>( | ||||
|   obj: T, | ||||
|   keys: readonly string[] | ||||
| ): T { | ||||
|   const orderedEntries = keys | ||||
|     .filter((key) => key in obj) | ||||
|     .map((key) => [key, obj[key]] as const); | ||||
|  | ||||
|   const extraEntries = Object.entries(obj).filter( | ||||
|     ([key]) => !keys.includes(key) | ||||
|   ); | ||||
|  | ||||
|   return Object.fromEntries([...orderedEntries, ...extraEntries]) as T; | ||||
| } | ||||
| @@ -14,7 +14,7 @@ export default function parseAspectRatio(input: string) { | ||||
|   } | ||||
|   try { | ||||
|     if (input.endsWith("%")) { | ||||
|       return { w: 100, h: parseOrThrow(input.substr(0, input.length - 1)) }; | ||||
|       return { w: 100, h: parseOrThrow(input.slice(0, -1)) }; | ||||
|     } | ||||
|  | ||||
|     const arr = input.replace(":", "x").split("x"); | ||||
|   | ||||
							
								
								
									
										116
									
								
								src/common/util/swipe-gesture-recognizer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/common/util/swipe-gesture-recognizer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| export interface SwipeGestureResult { | ||||
|   velocity: number; | ||||
|   delta: number; | ||||
|   isSwipe: boolean; | ||||
|   isDownwardSwipe: boolean; | ||||
| } | ||||
|  | ||||
| export interface SwipeGestureConfig { | ||||
|   velocitySwipeThreshold?: number; | ||||
|   movementTimeThreshold?: number; | ||||
| } | ||||
|  | ||||
| const VELOCITY_SWIPE_THRESHOLD = 0.5; // px/ms | ||||
| const MOVEMENT_TIME_THRESHOLD = 100; // ms | ||||
|  | ||||
| /** | ||||
|  * Recognizes swipe gestures and calculates velocity for touch interactions. | ||||
|  * Tracks touch movement and provides velocity-based and position-based gesture detection. | ||||
|  */ | ||||
| export class SwipeGestureRecognizer { | ||||
|   private _startY = 0; | ||||
|  | ||||
|   private _delta = 0; | ||||
|  | ||||
|   private _startTime = 0; | ||||
|  | ||||
|   private _lastY = 0; | ||||
|  | ||||
|   private _lastTime = 0; | ||||
|  | ||||
|   private _velocityThreshold: number; | ||||
|  | ||||
|   private _movementTimeThreshold: number; | ||||
|  | ||||
|   constructor(config: SwipeGestureConfig = {}) { | ||||
|     this._velocityThreshold = | ||||
|       config.velocitySwipeThreshold ?? VELOCITY_SWIPE_THRESHOLD; // px/ms | ||||
|     this._movementTimeThreshold = | ||||
|       config.movementTimeThreshold ?? MOVEMENT_TIME_THRESHOLD; // ms | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initialize gesture tracking with starting touch position | ||||
|    */ | ||||
|   public start(clientY: number): void { | ||||
|     const now = Date.now(); | ||||
|     this._startY = clientY; | ||||
|     this._startTime = now; | ||||
|     this._lastY = clientY; | ||||
|     this._lastTime = now; | ||||
|     this._delta = 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update gesture state during movement | ||||
|    * Returns the current delta (negative when dragging down) | ||||
|    */ | ||||
|   public move(clientY: number): number { | ||||
|     const now = Date.now(); | ||||
|     this._delta = this._startY - clientY; | ||||
|     this._lastY = clientY; | ||||
|     this._lastTime = now; | ||||
|     return this._delta; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Calculate final gesture result when touch ends | ||||
|    */ | ||||
|   public end(): SwipeGestureResult { | ||||
|     const velocity = this.getVelocity(); | ||||
|     const hasSignificantVelocity = Math.abs(velocity) > this._velocityThreshold; | ||||
|  | ||||
|     return { | ||||
|       velocity, | ||||
|       delta: this._delta, | ||||
|       isSwipe: hasSignificantVelocity, | ||||
|       isDownwardSwipe: velocity > 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get current drag delta (negative when dragging down) | ||||
|    */ | ||||
|   public getDelta(): number { | ||||
|     return this._delta; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Calculate velocity based on recent movement | ||||
|    * Returns 0 if no recent movement detected | ||||
|    * Positive velocity means downward swipe | ||||
|    */ | ||||
|   public getVelocity(): number { | ||||
|     const now = Date.now(); | ||||
|     const timeSinceLastMove = now - this._lastTime; | ||||
|  | ||||
|     // Only consider velocity if the last movement was recent | ||||
|     if (timeSinceLastMove >= this._movementTimeThreshold) { | ||||
|       return 0; | ||||
|     } | ||||
|  | ||||
|     const timeDelta = this._lastTime - this._startTime; | ||||
|     return timeDelta > 0 ? (this._lastY - this._startY) / timeDelta : 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Reset all tracking state | ||||
|    */ | ||||
|   public reset(): void { | ||||
|     this._startY = 0; | ||||
|     this._delta = 0; | ||||
|     this._startTime = 0; | ||||
|     this._lastY = 0; | ||||
|     this._lastTime = 0; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										8
									
								
								src/common/util/xss.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/common/util/xss.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| import xss from "xss"; | ||||
|  | ||||
| export const filterXSS = (html: string) => | ||||
|   xss(html, { | ||||
|     whiteList: {}, | ||||
|     stripIgnoreTag: true, | ||||
|     stripIgnoreTagBody: true, | ||||
|   }); | ||||
| @@ -6,6 +6,7 @@ import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; | ||||
| import "./ha-progress-button"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import type { Appearance } from "../ha-button"; | ||||
|  | ||||
| @customElement("ha-call-service-button") | ||||
| class HaCallServiceButton extends LitElement { | ||||
| @@ -25,12 +26,14 @@ class HaCallServiceButton extends LitElement { | ||||
|  | ||||
|   @property() public confirmation?; | ||||
|  | ||||
|   @property() public appearance: Appearance = "plain"; | ||||
|  | ||||
|   public render(): TemplateResult { | ||||
|     return html` | ||||
|       <ha-progress-button | ||||
|         .progress=${this.progress} | ||||
|         .disabled=${this.disabled} | ||||
|         appearance="plain" | ||||
|         .appearance=${this.appearance} | ||||
|         @click=${this._buttonTapped} | ||||
|         tabindex="0" | ||||
|       > | ||||
|   | ||||
| @@ -1,21 +1,22 @@ | ||||
| import type { LineSeriesOption } from "echarts"; | ||||
|  | ||||
| export function downSampleLineData( | ||||
|   data: LineSeriesOption["data"], | ||||
|   chartWidth: number, | ||||
| export function downSampleLineData< | ||||
|   T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number], | ||||
| >( | ||||
|   data: T[] | undefined, | ||||
|   maxDetails: number, | ||||
|   minX?: number, | ||||
|   maxX?: number | ||||
| ) { | ||||
|   if (!data || data.length < 10) { | ||||
|     return data; | ||||
| ): T[] { | ||||
|   if (!data) { | ||||
|     return []; | ||||
|   } | ||||
|   const width = chartWidth * window.devicePixelRatio; | ||||
|   if (data.length <= width) { | ||||
|   if (data.length <= maxDetails) { | ||||
|     return data; | ||||
|   } | ||||
|   const min = minX ?? getPointData(data[0]!)[0]; | ||||
|   const max = maxX ?? getPointData(data[data.length - 1]!)[0]; | ||||
|   const step = Math.floor((max - min) / width); | ||||
|   const step = Math.ceil((max - min) / Math.floor(maxDetails)); | ||||
|   const frames = new Map< | ||||
|     number, | ||||
|     { | ||||
| @@ -47,7 +48,7 @@ export function downSampleLineData( | ||||
|   } | ||||
|  | ||||
|   // Convert frames back to points | ||||
|   const result: typeof data = []; | ||||
|   const result: T[] = []; | ||||
|   for (const [_i, frame] of frames) { | ||||
|     // Use min/max points to preserve visual accuracy | ||||
|     // The order of the data must be preserved so max may be before min | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { consume } from "@lit/context"; | ||||
| import { ResizeController } from "@lit-labs/observers/resize-controller"; | ||||
| import { consume } from "@lit/context"; | ||||
| import { mdiChevronDown, mdiChevronUp, mdiRestart } from "@mdi/js"; | ||||
| import { differenceInMinutes } from "date-fns"; | ||||
| import type { DataZoomComponentOption } from "echarts/components"; | ||||
| @@ -7,27 +7,28 @@ import type { EChartsType } from "echarts/core"; | ||||
| import type { | ||||
|   ECElementEvent, | ||||
|   LegendComponentOption, | ||||
|   LineSeriesOption, | ||||
|   XAXisOption, | ||||
|   YAXisOption, | ||||
|   LineSeriesOption, | ||||
| } from "echarts/types/dist/shared"; | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| 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 { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { listenMediaQuery } from "../../common/dom/media_query"; | ||||
| import { themesContext } from "../../data/context"; | ||||
| import type { Themes } from "../../data/ws-themes"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { isMac } from "../../util/is_mac"; | ||||
| import "../ha-icon-button"; | ||||
| import { formatTimeLabel } from "./axis-label"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import "../chips/ha-assist-chip"; | ||||
| import "../ha-icon-button"; | ||||
| import { filterXSS } from "../../common/util/xss"; | ||||
| import { formatTimeLabel } from "./axis-label"; | ||||
| import { downSampleLineData } from "./down-sample"; | ||||
|  | ||||
| export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; | ||||
| @@ -63,6 +64,9 @@ export class HaChartBase extends LitElement { | ||||
|   @property({ attribute: "small-controls", type: Boolean }) | ||||
|   public smallControls?: boolean; | ||||
|  | ||||
|   @property({ attribute: "hide-reset-button", type: Boolean }) | ||||
|   public hideResetButton?: boolean; | ||||
|  | ||||
|   // extraComponents is not reactive and should not trigger updates | ||||
|   public extraComponents?: any[]; | ||||
|  | ||||
| @@ -84,9 +88,19 @@ export class HaChartBase extends LitElement { | ||||
|  | ||||
|   private _lastTapTime?: number; | ||||
|  | ||||
|   private _shouldResizeChart = false; | ||||
|  | ||||
|   // @ts-ignore | ||||
|   private _resizeController = new ResizeController(this, { | ||||
|     callback: () => this.chart?.resize(), | ||||
|     callback: () => { | ||||
|       if (this.chart) { | ||||
|         if (!this.chart.getZr().animation.isFinished()) { | ||||
|           this._shouldResizeChart = true; | ||||
|         } else { | ||||
|           this.chart.resize(); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   private _loading = false; | ||||
| @@ -215,7 +229,7 @@ export class HaChartBase extends LitElement { | ||||
|         </div> | ||||
|         ${this._renderLegend()} | ||||
|         <div class="chart-controls ${classMap({ small: this.smallControls })}"> | ||||
|           ${this._isZoomed | ||||
|           ${this._isZoomed && !this.hideResetButton | ||||
|             ? html`<ha-icon-button | ||||
|                 class="zoom-reset" | ||||
|                 .path=${mdiRestart} | ||||
| @@ -342,7 +356,7 @@ export class HaChartBase extends LitElement { | ||||
|       if (this.chart) { | ||||
|         this.chart.dispose(); | ||||
|       } | ||||
|       const echarts = (await import("../../resources/echarts")).default; | ||||
|       const echarts = (await import("../../resources/echarts/echarts")).default; | ||||
|  | ||||
|       if (this.extraComponents?.length) { | ||||
|         echarts.use(this.extraComponents); | ||||
| @@ -353,23 +367,16 @@ export class HaChartBase extends LitElement { | ||||
|  | ||||
|       this.chart = echarts.init(container, "custom"); | ||||
|       this.chart.on("datazoom", (e: any) => { | ||||
|         const { start, end } = e.batch?.[0] ?? e; | ||||
|         this._isZoomed = start !== 0 || end !== 100; | ||||
|         this._zoomRatio = (end - start) / 100; | ||||
|         if (this._isTouchDevice) { | ||||
|           // zooming changes the axis pointer so we need to hide it | ||||
|           this.chart?.dispatchAction({ | ||||
|             type: "hideTip", | ||||
|             from: "datazoom", | ||||
|           }); | ||||
|         } | ||||
|         this._handleDataZoomEvent(e); | ||||
|       }); | ||||
|       this.chart.on("click", (e: ECElementEvent) => { | ||||
|         fireEvent(this, "chart-click", e); | ||||
|       }); | ||||
|  | ||||
|       if (!this.options?.dataZoom) { | ||||
|         this.chart.getZr().on("dblclick", this._handleClickZoom); | ||||
|       } | ||||
|       this.chart.on("finished", this._handleChartRenderFinished); | ||||
|       if (this._isTouchDevice) { | ||||
|         this.chart.getZr().on("click", (e: ECElementEvent) => { | ||||
|           if (!e.zrByTouch) { | ||||
| @@ -809,14 +816,15 @@ export class HaChartBase extends LitElement { | ||||
|             sampling: undefined, | ||||
|             data: downSampleLineData( | ||||
|               data as LineSeriesOption["data"], | ||||
|               this.clientWidth, | ||||
|               this.clientWidth * window.devicePixelRatio, | ||||
|               minX, | ||||
|               maxX | ||||
|             ), | ||||
|           }; | ||||
|         } | ||||
|       } | ||||
|       return { ...s, data }; | ||||
|       const name = filterXSS(String(s.name ?? s.id ?? "")); | ||||
|       return { ...s, name, data }; | ||||
|     }); | ||||
|     return series as ECOption["series"]; | ||||
|   } | ||||
| @@ -868,10 +876,60 @@ export class HaChartBase extends LitElement { | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   public zoom(start: number, end: number, silent = false) { | ||||
|     this.chart?.dispatchAction({ | ||||
|       type: "dataZoom", | ||||
|       start, | ||||
|       end, | ||||
|       silent, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _handleZoomReset() { | ||||
|     this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); | ||||
|   } | ||||
|  | ||||
|   private _handleDataZoomEvent(e: any) { | ||||
|     const zoomData = e.batch?.[0] ?? e; | ||||
|     let start = typeof zoomData.start === "number" ? zoomData.start : 0; | ||||
|     let end = typeof zoomData.end === "number" ? zoomData.end : 100; | ||||
|  | ||||
|     if ( | ||||
|       start === 0 && | ||||
|       end === 100 && | ||||
|       zoomData.startValue !== undefined && | ||||
|       zoomData.endValue !== undefined | ||||
|     ) { | ||||
|       const option = this.chart!.getOption(); | ||||
|       const xAxis = option.xAxis?.[0] ?? option.xAxis; | ||||
|  | ||||
|       if (xAxis?.min && xAxis?.max) { | ||||
|         const axisMin = new Date(xAxis.min).getTime(); | ||||
|         const axisMax = new Date(xAxis.max).getTime(); | ||||
|         const axisRange = axisMax - axisMin; | ||||
|  | ||||
|         start = Math.max( | ||||
|           0, | ||||
|           Math.min(100, ((zoomData.startValue - axisMin) / axisRange) * 100) | ||||
|         ); | ||||
|         end = Math.max( | ||||
|           0, | ||||
|           Math.min(100, ((zoomData.endValue - axisMin) / axisRange) * 100) | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     this._isZoomed = start !== 0 || end !== 100; | ||||
|     this._zoomRatio = (end - start) / 100; | ||||
|     if (this._isTouchDevice) { | ||||
|       this.chart?.dispatchAction({ | ||||
|         type: "hideTip", | ||||
|         from: "datazoom", | ||||
|       }); | ||||
|     } | ||||
|     fireEvent(this, "chart-zoom", { start, end }); | ||||
|   } | ||||
|  | ||||
|   private _legendClick(ev: any) { | ||||
|     if (!this.chart) { | ||||
|       return; | ||||
| @@ -898,6 +956,13 @@ export class HaChartBase extends LitElement { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _handleChartRenderFinished = () => { | ||||
|     if (this._shouldResizeChart) { | ||||
|       this.chart?.resize(); | ||||
|       this._shouldResizeChart = false; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
| @@ -929,7 +994,7 @@ export class HaChartBase extends LitElement { | ||||
|       right: 4px; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: 4px; | ||||
|       gap: var(--ha-space-1); | ||||
|     } | ||||
|     .chart-controls.small { | ||||
|       top: 0; | ||||
| @@ -938,7 +1003,7 @@ export class HaChartBase extends LitElement { | ||||
|     .chart-controls ha-icon-button, | ||||
|     .chart-controls ::slotted(ha-icon-button) { | ||||
|       background: var(--card-background-color); | ||||
|       border-radius: 4px; | ||||
|       border-radius: var(--ha-border-radius-sm); | ||||
|       --mdc-icon-button-size: 32px; | ||||
|       color: var(--primary-color); | ||||
|       border: 1px solid var(--divider-color); | ||||
| @@ -966,7 +1031,7 @@ export class HaChartBase extends LitElement { | ||||
|       flex-wrap: wrap; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       gap: 8px; | ||||
|       gap: var(--ha-space-2); | ||||
|     } | ||||
|     .chart-legend li { | ||||
|       height: 24px; | ||||
| @@ -991,7 +1056,7 @@ export class HaChartBase extends LitElement { | ||||
|     .chart-legend .bullet { | ||||
|       border-width: 1px; | ||||
|       border-style: solid; | ||||
|       border-radius: 50%; | ||||
|       border-radius: var(--ha-border-radius-circle); | ||||
|       display: block; | ||||
|       height: 16px; | ||||
|       width: 16px; | ||||
| @@ -1024,5 +1089,9 @@ declare global { | ||||
|     "dataset-hidden": { id: string }; | ||||
|     "dataset-unhidden": { id: string }; | ||||
|     "chart-click": ECElementEvent; | ||||
|     "chart-zoom": { | ||||
|       start: number; | ||||
|       end: number; | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; | ||||
| import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { listenMediaQuery } from "../../common/dom/media_query"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import "./ha-chart-base"; | ||||
| import type { HaChartBase } from "./ha-chart-base"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
|   | ||||
| @@ -1,14 +1,15 @@ | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { LitElement, html, css } from "lit"; | ||||
| import type { EChartsType } from "echarts/core"; | ||||
| import type { CallbackDataParams } from "echarts/types/dist/shared"; | ||||
| import type { SankeySeriesOption } from "echarts/types/dist/echarts"; | ||||
| import { SankeyChart } from "echarts/charts"; | ||||
| import type { CallbackDataParams } from "echarts/types/src/util/types"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { ResizeController } from "@lit-labs/observers/resize-controller"; | ||||
| import SankeyChart from "../../resources/echarts/components/sankey/install"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import { measureTextWidth } from "../../util/text"; | ||||
| import { filterXSS } from "../../common/util/xss"; | ||||
| import "./ha-chart-base"; | ||||
| import { NODE_SIZE } from "../trace/hat-graph-const"; | ||||
| import "../ha-alert"; | ||||
| @@ -38,7 +39,7 @@ type ProcessedLink = Link & { | ||||
|  | ||||
| const OVERFLOW_MARGIN = 5; | ||||
| const FONT_SIZE = 12; | ||||
| const NODE_GAP = 8; | ||||
| const NODE_GAP = 6; | ||||
| const LABEL_DISTANCE = 5; | ||||
|  | ||||
| @customElement("ha-sankey-chart") | ||||
| @@ -92,12 +93,12 @@ export class HaSankeyChart extends LitElement { | ||||
|       : data.value; | ||||
|     if (data.id) { | ||||
|       const node = this.data.nodes.find((n) => n.id === data.id); | ||||
|       return `${params.marker} ${node?.label ?? data.id}<br>${value}`; | ||||
|       return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`; | ||||
|     } | ||||
|     if (data.source && data.target) { | ||||
|       const source = this.data.nodes.find((n) => n.id === data.source); | ||||
|       const target = this.data.nodes.find((n) => n.id === data.target); | ||||
|       return `${source?.label ?? data.source} → ${target?.label ?? data.target}<br>${value}`; | ||||
|       return `${filterXSS(source?.label ?? data.source)} → ${filterXSS(target?.label ?? data.target)}<br>${value}`; | ||||
|     } | ||||
|     return null; | ||||
|   }; | ||||
| @@ -163,6 +164,7 @@ export class HaSankeyChart extends LitElement { | ||||
|       lineStyle: { | ||||
|         color: "gradient", | ||||
|         opacity: 0.4, | ||||
|         curveness: 0.5, | ||||
|       }, | ||||
|       layoutIterations: 0, | ||||
|       label: { | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl"; | ||||
| import type { LineChartEntity, LineChartState } from "../../data/history"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; | ||||
| import { | ||||
|   getNumberFormatOptions, | ||||
| @@ -66,6 +66,9 @@ export class StateHistoryChartLine extends LitElement { | ||||
|   @property({ attribute: "expand-legend", type: Boolean }) | ||||
|   public expandLegend?: boolean; | ||||
|  | ||||
|   @property({ attribute: "hide-reset-button", type: Boolean }) | ||||
|   public hideResetButton?: boolean; | ||||
|  | ||||
|   @state() private _chartData: LineSeriesOption[] = []; | ||||
|  | ||||
|   @state() private _entityIds: string[] = []; | ||||
| @@ -94,7 +97,9 @@ export class StateHistoryChartLine extends LitElement { | ||||
|         style=${styleMap({ height: this.height })} | ||||
|         @dataset-hidden=${this._datasetHidden} | ||||
|         @dataset-unhidden=${this._datasetUnhidden} | ||||
|         @chart-zoom=${this._handleDataZoom} | ||||
|         .expandLegend=${this.expandLegend} | ||||
|         .hideResetButton=${this.hideResetButton} | ||||
|       ></ha-chart-base> | ||||
|     `; | ||||
|   } | ||||
| @@ -192,6 +197,19 @@ export class StateHistoryChartLine extends LitElement { | ||||
|     this._hiddenStats.delete(ev.detail.id); | ||||
|   } | ||||
|  | ||||
|   public zoom(start: number, end: number) { | ||||
|     const chartBase = this.shadowRoot!.querySelector("ha-chart-base")!; | ||||
|     chartBase.zoom(start, end, true); | ||||
|   } | ||||
|  | ||||
|   private _handleDataZoom(ev: CustomEvent) { | ||||
|     fireEvent(this, "chart-zoom-with-index", { | ||||
|       start: ev.detail.start ?? 0, | ||||
|       end: ev.detail.end ?? 100, | ||||
|       chartIndex: this.chartIndex, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   public willUpdate(changedProps: PropertyValues) { | ||||
|     if ( | ||||
|       changedProps.has("data") || | ||||
|   | ||||
| @@ -15,8 +15,8 @@ import type { TimelineEntity } from "../../data/history"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | ||||
| import { computeTimelineColor } from "./timeline-color"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import echarts from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import echarts from "../../resources/echarts/echarts"; | ||||
| import { luminosity } from "../../common/color/rgb"; | ||||
| import { hex2rgb } from "../../common/color/convert-color"; | ||||
| import { measureTextWidth } from "../../util/text"; | ||||
| @@ -51,6 +51,9 @@ export class StateHistoryChartTimeline extends LitElement { | ||||
|  | ||||
|   @property({ attribute: false, type: Number }) public chartIndex?; | ||||
|  | ||||
|   @property({ attribute: "hide-reset-button", type: Boolean }) | ||||
|   public hideResetButton?: boolean; | ||||
|  | ||||
|   @state() private _chartData: CustomSeriesOption[] = []; | ||||
|  | ||||
|   @state() private _chartOptions?: ECOption; | ||||
| @@ -68,6 +71,8 @@ export class StateHistoryChartTimeline extends LitElement { | ||||
|         .data=${this._chartData as ECOption["series"]} | ||||
|         small-controls | ||||
|         @chart-click=${this._handleChartClick} | ||||
|         @chart-zoom=${this._handleDataZoom} | ||||
|         .hideResetButton=${this.hideResetButton} | ||||
|       ></ha-chart-base> | ||||
|     `; | ||||
|   } | ||||
| @@ -256,6 +261,19 @@ export class StateHistoryChartTimeline extends LitElement { | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   public zoom(start: number, end: number) { | ||||
|     const chartBase = this.shadowRoot!.querySelector("ha-chart-base")!; | ||||
|     chartBase.zoom(start, end, true); | ||||
|   } | ||||
|  | ||||
|   private _handleDataZoom(ev: CustomEvent) { | ||||
|     fireEvent(this, "chart-zoom-with-index", { | ||||
|       start: ev.detail.start ?? 0, | ||||
|       end: ev.detail.end ?? 100, | ||||
|       chartIndex: this.chartIndex, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _generateData() { | ||||
|     const computedStyles = getComputedStyle(this); | ||||
|     let stateHistory = this.data; | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { css, html, LitElement } 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 { | ||||
| @@ -11,6 +12,10 @@ 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-svg-icon"; | ||||
| import "./state-history-chart-line"; | ||||
| import "./state-history-chart-timeline"; | ||||
|  | ||||
| @@ -29,6 +34,11 @@ const chunkData = (inputArray: any[], chunks: number) => | ||||
| declare global { | ||||
|   interface HASSDomEvents { | ||||
|     "y-width-changed": { value: number; chartIndex: number }; | ||||
|     "chart-zoom-with-index": { | ||||
|       start: number; | ||||
|       end: number; | ||||
|       chartIndex: number; | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -74,6 +84,9 @@ export class StateHistoryCharts extends LitElement { | ||||
|   @property({ attribute: "expand-legend", type: Boolean }) | ||||
|   public expandLegend?: boolean; | ||||
|  | ||||
|   @property({ attribute: "sync-charts", type: Boolean }) | ||||
|   public syncCharts = false; | ||||
|  | ||||
|   private _computedStartTime!: Date; | ||||
|  | ||||
|   private _computedEndTime!: Date; | ||||
| @@ -84,6 +97,10 @@ export class StateHistoryCharts extends LitElement { | ||||
|  | ||||
|   @state() private _chartCount = 0; | ||||
|  | ||||
|   @state() private _hasZoomedCharts = false; | ||||
|  | ||||
|   private _isSyncing = false; | ||||
|  | ||||
|   // @ts-ignore | ||||
|   @restoreScroll(".container") private _savedScrollPos?: number; | ||||
|  | ||||
| @@ -115,19 +132,36 @@ export class StateHistoryCharts extends LitElement { | ||||
|     // eslint-disable-next-line lit/no-this-assign-in-render | ||||
|     this._chartCount = combinedItems.length; | ||||
|  | ||||
|     return this.virtualize | ||||
|       ? html`<div class="container ha-scrollbar" @scroll=${this._saveScrollPos}> | ||||
|           <lit-virtualizer | ||||
|             scroller | ||||
|             class="ha-scrollbar" | ||||
|             .items=${combinedItems} | ||||
|             .renderItem=${this._renderHistoryItem} | ||||
|     return html` | ||||
|       ${this.virtualize | ||||
|         ? html`<div | ||||
|             class="container ha-scrollbar" | ||||
|             @scroll=${this._saveScrollPos} | ||||
|           > | ||||
|           </lit-virtualizer> | ||||
|         </div>` | ||||
|       : html`${combinedItems.map((item, index) => | ||||
|           this._renderHistoryItem(item, index) | ||||
|         )}`; | ||||
|             <lit-virtualizer | ||||
|               scroller | ||||
|               class="ha-scrollbar" | ||||
|               .items=${combinedItems} | ||||
|               .renderItem=${this._renderHistoryItem} | ||||
|             > | ||||
|             </lit-virtualizer> | ||||
|           </div>` | ||||
|         : html`${combinedItems.map((item, index) => | ||||
|             this._renderHistoryItem(item, index) | ||||
|           )}`} | ||||
|       ${this.syncCharts && this._hasZoomedCharts | ||||
|         ? html`<ha-fab | ||||
|             slot="fab" | ||||
|             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>` | ||||
|         : nothing} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderHistoryItem: RenderItemFunction< | ||||
| @@ -156,8 +190,10 @@ export class StateHistoryCharts extends LitElement { | ||||
|           .maxYAxis=${this.maxYAxis} | ||||
|           .fitYData=${this.fitYData} | ||||
|           @y-width-changed=${this._yWidthChanged} | ||||
|           @chart-zoom-with-index=${this._handleTimelineSync} | ||||
|           .height=${this.virtualize ? undefined : this.height} | ||||
|           .expandLegend=${this.expandLegend} | ||||
|           ?hide-reset-button=${this.syncCharts} | ||||
|         ></state-history-chart-line> | ||||
|       </div> `; | ||||
|     } | ||||
| @@ -175,6 +211,8 @@ export class StateHistoryCharts extends LitElement { | ||||
|         .chartIndex=${index} | ||||
|         .clickForMoreInfo=${this.clickForMoreInfo} | ||||
|         @y-width-changed=${this._yWidthChanged} | ||||
|         @chart-zoom-with-index=${this._handleTimelineSync} | ||||
|         ?hide-reset-button=${this.syncCharts} | ||||
|       ></state-history-chart-timeline> | ||||
|     </div> `; | ||||
|   }; | ||||
| @@ -264,6 +302,66 @@ export class StateHistoryCharts extends LitElement { | ||||
|     this._maxYWidth = Math.max(...Object.values(this._childYWidths), 0); | ||||
|   } | ||||
|  | ||||
|   private _handleTimelineSync( | ||||
|     e: CustomEvent<HASSDomEvents["chart-zoom-with-index"]> | ||||
|   ) { | ||||
|     if (!this.syncCharts || this._isSyncing) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const { start, end, chartIndex } = e.detail; | ||||
|  | ||||
|     this._hasZoomedCharts = start !== 0 || end !== 100; | ||||
|     this._syncZoomToAllCharts(start, end, chartIndex); | ||||
|   } | ||||
|  | ||||
|   private _syncZoomToAllCharts( | ||||
|     start: number, | ||||
|     end: number, | ||||
|     sourceChartIndex?: number | ||||
|   ) { | ||||
|     this._isSyncing = true; | ||||
|  | ||||
|     requestAnimationFrame(() => { | ||||
|       const chartComponents = this.renderRoot.querySelectorAll( | ||||
|         "state-history-chart-line, state-history-chart-timeline" | ||||
|       ) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[]; | ||||
|  | ||||
|       chartComponents.forEach((chartComponent, index) => { | ||||
|         if (index === sourceChartIndex) { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         if ("zoom" in chartComponent) { | ||||
|           chartComponent.zoom(start, end); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       this._isSyncing = false; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _handleGlobalZoomReset() { | ||||
|     this._hasZoomedCharts = false; | ||||
|     this._isSyncing = true; | ||||
|  | ||||
|     requestAnimationFrame(() => { | ||||
|       const chartComponents = this.renderRoot.querySelectorAll( | ||||
|         "state-history-chart-line, state-history-chart-timeline" | ||||
|       ); | ||||
|  | ||||
|       chartComponents.forEach((chartComponent: any) => { | ||||
|         const chartBase = | ||||
|           chartComponent.renderRoot?.querySelector("ha-chart-base"); | ||||
|  | ||||
|         if (chartBase && chartBase.chart) { | ||||
|           chartBase.zoom(0, 100); | ||||
|         } | ||||
|       }); | ||||
|       this._isSyncing = false; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _isHistoryEmpty(): boolean { | ||||
|     const historyDataEmpty = | ||||
|       !this.historyData || | ||||
| @@ -345,6 +443,12 @@ export class StateHistoryCharts extends LitElement { | ||||
|     state-history-chart-line { | ||||
|       width: 100%; | ||||
|     } | ||||
|     .reset-button { | ||||
|       position: fixed; | ||||
|       bottom: calc(24px + var(--safe-area-inset-bottom)); | ||||
|       right: calc(24px + var(--safe-area-inset-bottom)); | ||||
|       z-index: 1; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -29,7 +29,7 @@ import { | ||||
|   getStatisticMetadata, | ||||
|   statisticsHaveType, | ||||
| } from "../../data/recorder"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import type { CustomLegendOption } from "./ha-chart-base"; | ||||
| import "./ha-chart-base"; | ||||
|   | ||||
| @@ -3,9 +3,11 @@ import { getGraphColorByIndex } from "../../common/color/colors"; | ||||
| import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color"; | ||||
| import { labBrighten } from "../../common/color/lab"; | ||||
| import { computeDomain } from "../../common/entity/compute_domain"; | ||||
| import { stateColor } from "../../common/entity/state_color"; | ||||
| import { stateColorProperties } from "../../common/entity/state_color"; | ||||
| import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; | ||||
| import { computeCssValue } from "../../resources/css-variables"; | ||||
| import { computeStateDomain } from "../../common/entity/compute_state_domain"; | ||||
| import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states"; | ||||
|  | ||||
| const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = { | ||||
|   media_player: { | ||||
| @@ -30,21 +32,49 @@ function computeTimelineStateColor( | ||||
|     return computeCssValue("--history-unknown-color", computedStyles); | ||||
|   } | ||||
|  | ||||
|   const color = stateColor(computedStyles, stateObj, state); | ||||
|   const properties = stateColorProperties(stateObj, state); | ||||
|  | ||||
|   if (!color) return undefined; | ||||
|   if (!properties) { | ||||
|     return undefined; | ||||
|   } | ||||
|  | ||||
|   const rgb = computeCssValue(properties, computedStyles); | ||||
|  | ||||
|   if (!rgb) return undefined; | ||||
|  | ||||
|   const domain = computeDomain(stateObj.entity_id); | ||||
|   const shade = DOMAIN_STATE_SHADES[domain]?.[state] as number | number; | ||||
|   if (!shade) { | ||||
|     return color; | ||||
|     return rgb; | ||||
|   } | ||||
|   return lab2hex(labBrighten(rgb2lab(hex2rgb(color)), shade)); | ||||
|   return lab2hex(labBrighten(rgb2lab(hex2rgb(rgb)), shade)); | ||||
| } | ||||
|  | ||||
| let colorIndex = 0; | ||||
| const stateColorMap = new Map<string, string>(); | ||||
|  | ||||
| function computeTimelineEnumColor( | ||||
|   state: string, | ||||
|   computedStyles: CSSStyleDeclaration, | ||||
|   stateObj?: HassEntity | ||||
| ): string | undefined { | ||||
|   if (!stateObj) { | ||||
|     return undefined; | ||||
|   } | ||||
|   const domain = computeStateDomain(stateObj); | ||||
|   const states = | ||||
|     FIXED_DOMAIN_STATES[domain] || | ||||
|     (domain === "sensor" && | ||||
|       stateObj.attributes.device_class === "enum" && | ||||
|       stateObj.attributes.options) || | ||||
|     []; | ||||
|   const idx = states.indexOf(state); | ||||
|   if (idx === -1) { | ||||
|     return undefined; | ||||
|   } | ||||
|   return getGraphColorByIndex(idx, computedStyles); | ||||
| } | ||||
|  | ||||
| function computeTimeLineGenericColor( | ||||
|   state: string, | ||||
|   computedStyles: CSSStyleDeclaration | ||||
| @@ -65,6 +95,7 @@ export function computeTimelineColor( | ||||
| ): string { | ||||
|   return ( | ||||
|     computeTimelineStateColor(state, computedStyles, stateObj) || | ||||
|     computeTimelineEnumColor(state, computedStyles, stateObj) || | ||||
|     computeTimeLineGenericColor(state, computedStyles) | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles"; | ||||
| import { FilterChip } from "@material/web/chips/internal/filter-chip"; | ||||
| import { styles } from "@material/web/chips/internal/filter-styles"; | ||||
| import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles"; | ||||
| import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles"; | ||||
| import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles"; | ||||
| import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles"; | ||||
| import { css, html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
|  | ||||
| @@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip { | ||||
|           var(--rgb-primary-text-color), | ||||
|           0.15 | ||||
|         ); | ||||
|         border-radius: var(--ha-border-radius-md); | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||
| import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||
| import type { CSSResultGroup } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| @@ -129,7 +129,7 @@ export class DialogDataTableSettings extends LitElement { | ||||
|                   ${canMove && isVisible | ||||
|                     ? html`<ha-svg-icon | ||||
|                         class="handle" | ||||
|                         .path=${mdiDrag} | ||||
|                         .path=${mdiDragHorizontalVariant} | ||||
|                         slot="graphic" | ||||
|                       ></ha-svg-icon>` | ||||
|                     : nothing} | ||||
| @@ -290,7 +290,9 @@ export class DialogDataTableSettings extends LitElement { | ||||
|           ha-dialog { | ||||
|             --vertical-align-dialog: flex-start; | ||||
|             --dialog-surface-margin-top: 250px; | ||||
|             --ha-dialog-border-radius: 28px 28px 0 0; | ||||
|             --ha-dialog-border-radius: var(--ha-border-radius-4xl) | ||||
|               var(--ha-border-radius-4xl) var(--ha-border-radius-square) | ||||
|               var(--ha-border-radius-square); | ||||
|             --mdc-dialog-min-height: calc(100% - 250px); | ||||
|             --mdc-dialog-max-height: calc(100% - 250px); | ||||
|           } | ||||
|   | ||||
| @@ -1053,7 +1053,7 @@ export class HaDataTable extends LitElement { | ||||
|  | ||||
|         .mdc-data-table { | ||||
|           background-color: var(--data-table-background-color); | ||||
|           border-radius: 4px; | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|           border-width: 1px; | ||||
|           border-style: solid; | ||||
|           border-color: var(--divider-color); | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| import { expose } from "comlink"; | ||||
| import { stringCompare, ipCompare } from "../../common/string/compare"; | ||||
| import Fuse from "fuse.js"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { ipCompare, stringCompare } from "../../common/string/compare"; | ||||
| import { stripDiacritics } from "../../common/string/strip-diacritics"; | ||||
| import { HaFuse } from "../../resources/fuse"; | ||||
| import type { | ||||
|   ClonedDataTableColumnData, | ||||
|   DataTableRowData, | ||||
| @@ -8,29 +11,48 @@ import type { | ||||
|   SortingDirection, | ||||
| } from "./ha-data-table"; | ||||
|  | ||||
| const fuseIndex = memoizeOne( | ||||
|   (data: DataTableRowData[], columns: SortableColumnContainer) => { | ||||
|     const searchKeys = new Set<string>(); | ||||
|     Object.entries(columns).forEach(([key, column]) => { | ||||
|       if (column.filterable) { | ||||
|         searchKeys.add( | ||||
|           column.filterKey | ||||
|             ? `${column.valueColumn || key}.${column.filterKey}` | ||||
|             : key | ||||
|         ); | ||||
|       } | ||||
|     }); | ||||
|     return Fuse.createIndex([...searchKeys], data); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| const filterData = ( | ||||
|   data: DataTableRowData[], | ||||
|   columns: SortableColumnContainer, | ||||
|   filter: string | ||||
| ) => { | ||||
|   filter = stripDiacritics(filter.toLowerCase()); | ||||
|   return data.filter((row) => | ||||
|     Object.entries(columns).some((columnEntry) => { | ||||
|       const [key, column] = columnEntry; | ||||
|       if (column.filterable) { | ||||
|         const value = String( | ||||
|           column.filterKey | ||||
|             ? row[column.valueColumn || key][column.filterKey] | ||||
|             : row[column.valueColumn || key] | ||||
|         ); | ||||
|  | ||||
|         if (stripDiacritics(value).toLowerCase().includes(filter)) { | ||||
|           return true; | ||||
|         } | ||||
|       } | ||||
|       return false; | ||||
|     }) | ||||
|   if (filter === "") { | ||||
|     return data; | ||||
|   } | ||||
|  | ||||
|   const index = fuseIndex(data, columns); | ||||
|  | ||||
|   const fuse = new HaFuse( | ||||
|     data, | ||||
|     { shouldSort: false, minMatchCharLength: 1 }, | ||||
|     index | ||||
|   ); | ||||
|  | ||||
|   const searchResults = fuse.multiTermsSearch(filter); | ||||
|  | ||||
|   if (searchResults) { | ||||
|     return searchResults.map((result) => result.item); | ||||
|   } | ||||
|  | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| const sortData = ( | ||||
|   | ||||
| @@ -4,11 +4,11 @@ import Vue from "vue"; | ||||
| import DateRangePicker from "vue2-daterange-picker"; | ||||
| // @ts-ignore | ||||
| import dateRangePickerStyles from "vue2-daterange-picker/dist/vue2-daterange-picker.css"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { | ||||
|   localizeWeekdays, | ||||
|   localizeMonths, | ||||
|   localizeWeekdays, | ||||
| } from "../common/datetime/localize_date"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { mainWindow } from "../common/dom/get_main_window"; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||||
| @@ -177,7 +177,7 @@ class DateRangePickerElement extends WrappedElement { | ||||
|             top: auto; | ||||
|             box-shadow: var(--ha-card-box-shadow, none); | ||||
|             background-color: var(--card-background-color); | ||||
|             border-radius: var(--ha-card-border-radius, 12px); | ||||
|             border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg)); | ||||
|             border-width: var(--ha-card-border-width, 1px); | ||||
|             border-style: solid; | ||||
|             border-color: var( | ||||
| @@ -203,7 +203,7 @@ class DateRangePickerElement extends WrappedElement { | ||||
|           .daterangepicker .calendar-table th { | ||||
|             background-color: transparent; | ||||
|             color: var(--secondary-text-color); | ||||
|             border-radius: 0; | ||||
|             border-radius: var(--ha-border-radius-square); | ||||
|             outline: none; | ||||
|             min-width: 32px; | ||||
|             height: 32px; | ||||
| @@ -225,13 +225,13 @@ class DateRangePickerElement extends WrappedElement { | ||||
|             color: var(--text-primary-color); | ||||
|           } | ||||
|           .daterangepicker td.start-date.end-date { | ||||
|             border-radius: 50%; | ||||
|             border-radius: var(--ha-border-radius-circle); | ||||
|           } | ||||
|           .daterangepicker td.start-date { | ||||
|             border-radius: 50% 0 0 50%; | ||||
|             border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle); | ||||
|           } | ||||
|           .daterangepicker td.end-date { | ||||
|             border-radius: 0 50% 50% 0; | ||||
|             border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square); | ||||
|           } | ||||
|           .reportrange-text { | ||||
|             background: none !important; | ||||
| @@ -265,7 +265,7 @@ class DateRangePickerElement extends WrappedElement { | ||||
|             border: 1px solid var(--primary-color); | ||||
|             background-color: transparent; | ||||
|             color: var(--primary-color); | ||||
|             border-radius: 4px; | ||||
|             border-radius: var(--ha-border-radius-sm); | ||||
|             padding: 8px; | ||||
|             cursor: pointer; | ||||
|           } | ||||
| @@ -321,10 +321,10 @@ class DateRangePickerElement extends WrappedElement { | ||||
|               -webkit-transform: rotate(-45deg); | ||||
|             } | ||||
|             .daterangepicker td.start-date { | ||||
|               border-radius: 0 50% 50% 0; | ||||
|               border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square); | ||||
|             } | ||||
|             .daterangepicker td.end-date { | ||||
|               border-radius: 50% 0 0 50%; | ||||
|               border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle); | ||||
|             } | ||||
|             `; | ||||
|     } | ||||
|   | ||||
| @@ -5,24 +5,18 @@ import { customElement, property, query, state } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { computeAreaName } from "../../common/entity/compute_area_name"; | ||||
| import { | ||||
|   computeDeviceName, | ||||
|   computeDeviceNameDisplay, | ||||
| } from "../../common/entity/compute_device_name"; | ||||
| import { computeDomain } from "../../common/entity/compute_domain"; | ||||
| import { computeDeviceName } from "../../common/entity/compute_device_name"; | ||||
| import { getDeviceContext } from "../../common/entity/context/get_device_context"; | ||||
| import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; | ||||
| import { | ||||
|   getDeviceEntityDisplayLookup, | ||||
|   type DeviceEntityDisplayLookup, | ||||
|   getDevices, | ||||
|   type DevicePickerItem, | ||||
|   type DeviceRegistryEntry, | ||||
| } from "../../data/device_registry"; | ||||
| import { domainToName } from "../../data/integration"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { brandsUrl } from "../../util/brands-url"; | ||||
| import "../ha-generic-picker"; | ||||
| import type { HaGenericPicker } from "../ha-generic-picker"; | ||||
| import type { PickerComboBoxItem } from "../ha-picker-combo-box"; | ||||
|  | ||||
| export type HaDevicePickerDeviceFilterFunc = ( | ||||
|   device: DeviceRegistryEntry | ||||
| @@ -30,11 +24,6 @@ export type HaDevicePickerDeviceFilterFunc = ( | ||||
|  | ||||
| export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; | ||||
|  | ||||
| interface DevicePickerItem extends PickerComboBoxItem { | ||||
|   domain?: string; | ||||
|   domain_name?: string; | ||||
| } | ||||
|  | ||||
| @customElement("ha-device-picker") | ||||
| export class HaDevicePicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
| @@ -104,6 +93,8 @@ export class HaDevicePicker extends LitElement { | ||||
|  | ||||
|   @state() private _configEntryLookup: Record<string, ConfigEntry> = {}; | ||||
|  | ||||
|   private _getDevicesMemoized = memoizeOne(getDevices); | ||||
|  | ||||
|   protected firstUpdated(_changedProperties: PropertyValues): void { | ||||
|     super.firstUpdated(_changedProperties); | ||||
|     this._loadConfigEntries(); | ||||
| @@ -117,162 +108,18 @@ export class HaDevicePicker extends LitElement { | ||||
|   } | ||||
|  | ||||
|   private _getItems = () => | ||||
|     this._getDevices( | ||||
|       this.hass.devices, | ||||
|       this.hass.entities, | ||||
|     this._getDevicesMemoized( | ||||
|       this.hass, | ||||
|       this._configEntryLookup, | ||||
|       this.includeDomains, | ||||
|       this.excludeDomains, | ||||
|       this.includeDeviceClasses, | ||||
|       this.deviceFilter, | ||||
|       this.entityFilter, | ||||
|       this.excludeDevices | ||||
|       this.excludeDevices, | ||||
|       this.value | ||||
|     ); | ||||
|  | ||||
|   private _getDevices = memoizeOne( | ||||
|     ( | ||||
|       haDevices: HomeAssistant["devices"], | ||||
|       haEntities: HomeAssistant["entities"], | ||||
|       configEntryLookup: Record<string, ConfigEntry>, | ||||
|       includeDomains: this["includeDomains"], | ||||
|       excludeDomains: this["excludeDomains"], | ||||
|       includeDeviceClasses: this["includeDeviceClasses"], | ||||
|       deviceFilter: this["deviceFilter"], | ||||
|       entityFilter: this["entityFilter"], | ||||
|       excludeDevices: this["excludeDevices"] | ||||
|     ): DevicePickerItem[] => { | ||||
|       const devices = Object.values(haDevices); | ||||
|       const entities = Object.values(haEntities); | ||||
|  | ||||
|       let deviceEntityLookup: DeviceEntityDisplayLookup = {}; | ||||
|  | ||||
|       if ( | ||||
|         includeDomains || | ||||
|         excludeDomains || | ||||
|         includeDeviceClasses || | ||||
|         entityFilter | ||||
|       ) { | ||||
|         deviceEntityLookup = getDeviceEntityDisplayLookup(entities); | ||||
|       } | ||||
|  | ||||
|       let inputDevices = devices.filter( | ||||
|         (device) => device.id === this.value || !device.disabled_by | ||||
|       ); | ||||
|  | ||||
|       if (includeDomains) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return false; | ||||
|           } | ||||
|           return deviceEntityLookup[device.id].some((entity) => | ||||
|             includeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (excludeDomains) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return true; | ||||
|           } | ||||
|           return entities.every( | ||||
|             (entity) => | ||||
|               !excludeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (excludeDevices) { | ||||
|         inputDevices = inputDevices.filter( | ||||
|           (device) => !excludeDevices!.includes(device.id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (includeDeviceClasses) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return false; | ||||
|           } | ||||
|           return deviceEntityLookup[device.id].some((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             if (!stateObj) { | ||||
|               return false; | ||||
|             } | ||||
|             return ( | ||||
|               stateObj.attributes.device_class && | ||||
|               includeDeviceClasses.includes(stateObj.attributes.device_class) | ||||
|             ); | ||||
|           }); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (entityFilter) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return false; | ||||
|           } | ||||
|           return devEntities.some((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             if (!stateObj) { | ||||
|               return false; | ||||
|             } | ||||
|             return entityFilter(stateObj); | ||||
|           }); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (deviceFilter) { | ||||
|         inputDevices = inputDevices.filter( | ||||
|           (device) => | ||||
|             // We always want to include the device of the current value | ||||
|             device.id === this.value || deviceFilter!(device) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       const outputDevices = inputDevices.map<DevicePickerItem>((device) => { | ||||
|         const deviceName = computeDeviceNameDisplay( | ||||
|           device, | ||||
|           this.hass, | ||||
|           deviceEntityLookup[device.id] | ||||
|         ); | ||||
|  | ||||
|         const { area } = getDeviceContext(device, this.hass); | ||||
|  | ||||
|         const areaName = area ? computeAreaName(area) : undefined; | ||||
|  | ||||
|         const configEntry = device.primary_config_entry | ||||
|           ? configEntryLookup?.[device.primary_config_entry] | ||||
|           : undefined; | ||||
|  | ||||
|         const domain = configEntry?.domain; | ||||
|         const domainName = domain | ||||
|           ? domainToName(this.hass.localize, domain) | ||||
|           : undefined; | ||||
|  | ||||
|         return { | ||||
|           id: device.id, | ||||
|           label: "", | ||||
|           primary: | ||||
|             deviceName || | ||||
|             this.hass.localize("ui.components.device-picker.unnamed_device"), | ||||
|           secondary: areaName, | ||||
|           domain: configEntry?.domain, | ||||
|           domain_name: domainName, | ||||
|           search_labels: [deviceName, areaName, domain, domainName].filter( | ||||
|             Boolean | ||||
|           ) as string[], | ||||
|           sorting_label: deviceName || "zzz", | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|       return outputDevices; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _valueRenderer = memoizeOne( | ||||
|     (configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => { | ||||
|       const deviceId = value; | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user