mirror of
				https://github.com/home-assistant/frontend.git
				synced 2025-10-31 06:29:43 +00:00 
			
		
		
		
	Compare commits
	
		
			249 Commits
		
	
	
		
			clock-date
			...
			20251029.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 8f5875c30f | ||
|   | 517cd49f35 | ||
|   | 25d9fc94b2 | ||
|   | 7b188759e3 | ||
|   | 76772d1098 | ||
|   | 6052745ca0 | ||
|   | 89b9780345 | ||
|   | a607edca96 | ||
|   | 52eb3d8063 | ||
| ![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 | ||
|   | 3e749ec085 | ||
|   | ee2ec00069 | ||
|   | 0aa2941868 | ||
|   | 46cd1d5156 | ||
|   | 07a5c41fd4 | ||
|   | 4ad3c553d5 | ||
|   | d40cc448a5 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 43a23e6cdd | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | aa4dd1cf29 | ||
|   | 0ae55c39cc | ||
|   | 0bfacacc9e | ||
|   | c2f21c19af | ||
|   | e2f3f9d348 | ||
|   | 98d44950f8 | ||
|   | 8ae9edb1ef | ||
|   | 84c4396c13 | ||
|   | 6653333c38 | ||
|   | 8c19e080be | ||
|   | c649b1015a | ||
|   | 2b937a30e3 | ||
|   | b7815bfd86 | ||
|   | d94fa03411 | ||
|   | 0a7007ef9e | ||
|   | dd12136dee | ||
|   | 6e2f89fe3d | ||
|   | 092085b9af | ||
|   | 1c06eb8661 | ||
|   | c7e87b06b5 | ||
|   | 38c738c199 | ||
|   | e899587307 | ||
|   | c9feb0b75f | ||
|   | 10718c35d1 | ||
|   | 4dc6a37bad | ||
|   | ac49fc7aba | ||
|   | e4f008800b | ||
|   | 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 | ||
|   | 0b0ffd7bab | ||
|   | dfa77526a2 | ||
|   | 9a3bd6c613 | ||
|   | 1161de5746 | ||
|   | 9df8e20391 | ||
|   | 11047a9c95 | ||
|   | 18fa66f61c | ||
|   | 758a048f34 | ||
|   | ee0fc360b0 | ||
|   | 4012f95ec1 | ||
|   | 0336ce4606 | ||
|   | 9ba36ab7e2 | ||
|   | fe7a08a1b0 | ||
|   | 87a8f9cedc | ||
|   | 01df7e20ca | ||
|   | d181219522 | ||
|   | 6ae24b8135 | ||
|   | 8e009f24f9 | ||
|   | 53031f44ac | ||
|   | af5a988457 | ||
|   | bab0391a19 | ||
|   | 444123c47e | ||
|   | f123d34046 | ||
|   | 1b40f99f68 | ||
|   | b314b3ed2b | ||
|   | 59b8932969 | ||
|   | 107af753ec | ||
|   | 1f0acb3046 | ||
|   | 431e533929 | ||
|   | 02c845cbc6 | ||
|   | 628111ed20 | ||
|   | e825a9c02f | ||
|   | 7a35bddf36 | ||
|   | ad69270af8 | ||
|   | 404edf9483 | ||
|   | a166b4e9b6 | ||
|   | 7a285f11db | 
							
								
								
									
										4
									
								
								.github/workflows/cast_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/cast_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -26,7 +26,7 @@ jobs: | ||||
|           ref: dev | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -61,7 +61,7 @@ jobs: | ||||
|           ref: master | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
|   | ||||
							
								
								
									
										12
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -26,7 +26,7 @@ jobs: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -60,7 +60,7 @@ jobs: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -78,7 +78,7 @@ jobs: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -89,7 +89,7 @@ jobs: | ||||
|         env: | ||||
|           IS_TEST: "true" | ||||
|       - name: Upload bundle stats | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         with: | ||||
|           name: frontend-bundle-stats | ||||
|           path: build/stats/*.json | ||||
| @@ -102,7 +102,7 @@ jobs: | ||||
|       - name: Check out files from GitHub | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -113,7 +113,7 @@ jobs: | ||||
|         env: | ||||
|           IS_TEST: "true" | ||||
|       - name: Upload bundle stats | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         with: | ||||
|           name: supervisor-bundle-stats | ||||
|           path: build/stats/*.json | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -36,14 +36,14 @@ jobs: | ||||
|  | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 | ||||
|         uses: github/codeql-action/init@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@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 | ||||
|         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@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 | ||||
|         uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/demo_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/demo_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -27,7 +27,7 @@ jobs: | ||||
|           ref: dev | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -62,7 +62,7 @@ jobs: | ||||
|           ref: master | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/design_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/design_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -19,7 +19,7 @@ jobs: | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/design_preview.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/design_preview.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -24,7 +24,7 @@ jobs: | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -28,7 +28,7 @@ jobs: | ||||
|           python-version: ${{ env.PYTHON_VERSION }} | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -57,14 +57,14 @@ jobs: | ||||
|         run: tar -czvf translations.tar.gz translations | ||||
|  | ||||
|       - name: Upload build artifacts | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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@1707825cbfcc7452b2913d273414705415ae64d4 # v3.0.1 | ||||
|         uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0 | ||||
|         with: | ||||
|           key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} | ||||
|           token: ${{ github.token }} | ||||
|   | ||||
							
								
								
									
										14
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -34,7 +34,7 @@ jobs: | ||||
|         uses: home-assistant/actions/helpers/verify-version@master | ||||
|  | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -55,7 +55,7 @@ jobs: | ||||
|           script/release | ||||
|  | ||||
|       - name: Upload release assets | ||||
|         uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 | ||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||
|         with: | ||||
|           files: | | ||||
|             dist/*.whl | ||||
| @@ -75,7 +75,7 @@ jobs: | ||||
|  | ||||
|       # home-assistant/wheels doesn't support SHA pinning | ||||
|       - name: Build wheels | ||||
|         uses: home-assistant/wheels@2025.09.1 | ||||
|         uses: home-assistant/wheels@2025.10.0 | ||||
|         with: | ||||
|           abi: cp313 | ||||
|           tag: musllinux_1_2 | ||||
| @@ -93,7 +93,7 @@ jobs: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -108,7 +108,7 @@ jobs: | ||||
|       - name: Tar folder | ||||
|         run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist . | ||||
|       - name: Upload release asset | ||||
|         uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 | ||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||
|         with: | ||||
|           files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz | ||||
|  | ||||
| @@ -122,7 +122,7 @@ jobs: | ||||
|       - name: Checkout the repository | ||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||
|       - name: Setup Node | ||||
|         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 | ||||
|         with: | ||||
|           node-version-file: ".nvmrc" | ||||
|           cache: yarn | ||||
| @@ -137,6 +137,6 @@ jobs: | ||||
|       - name: Tar folder | ||||
|         run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build . | ||||
|       - name: Upload release asset | ||||
|         uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 | ||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||
|         with: | ||||
|           files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz | ||||
|   | ||||
| @@ -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. | ||||
|  | ||||
|   | ||||
							
								
								
									
										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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										71
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										71
									
								
								package.json
									
									
									
									
									
								
							| @@ -28,32 +28,32 @@ | ||||
|   "dependencies": { | ||||
|     "@babel/runtime": "7.28.4", | ||||
|     "@braintree/sanitize-url": "7.1.1", | ||||
|     "@codemirror/autocomplete": "6.19.0", | ||||
|     "@codemirror/commands": "6.9.0", | ||||
|     "@codemirror/autocomplete": "6.19.1", | ||||
|     "@codemirror/commands": "6.10.0", | ||||
|     "@codemirror/language": "6.11.3", | ||||
|     "@codemirror/legacy-modes": "6.5.2", | ||||
|     "@codemirror/search": "6.5.11", | ||||
|     "@codemirror/state": "6.5.2", | ||||
|     "@codemirror/view": "6.38.4", | ||||
|     "@codemirror/view": "6.38.6", | ||||
|     "@date-fns/tz": "1.4.1", | ||||
|     "@egjs/hammerjs": "2.0.17", | ||||
|     "@formatjs/intl-datetimeformat": "6.18.1", | ||||
|     "@formatjs/intl-displaynames": "6.8.12", | ||||
|     "@formatjs/intl-durationformat": "0.7.5", | ||||
|     "@formatjs/intl-datetimeformat": "6.18.2", | ||||
|     "@formatjs/intl-displaynames": "6.8.13", | ||||
|     "@formatjs/intl-durationformat": "0.7.6", | ||||
|     "@formatjs/intl-getcanonicallocales": "2.5.6", | ||||
|     "@formatjs/intl-listformat": "7.7.12", | ||||
|     "@formatjs/intl-locale": "4.2.12", | ||||
|     "@formatjs/intl-numberformat": "8.15.5", | ||||
|     "@formatjs/intl-pluralrules": "5.4.5", | ||||
|     "@formatjs/intl-relativetimeformat": "11.4.12", | ||||
|     "@formatjs/intl-listformat": "7.7.13", | ||||
|     "@formatjs/intl-locale": "4.2.13", | ||||
|     "@formatjs/intl-numberformat": "8.15.6", | ||||
|     "@formatjs/intl-pluralrules": "5.4.6", | ||||
|     "@formatjs/intl-relativetimeformat": "11.4.13", | ||||
|     "@fullcalendar/core": "6.1.19", | ||||
|     "@fullcalendar/daygrid": "6.1.19", | ||||
|     "@fullcalendar/interaction": "6.1.19", | ||||
|     "@fullcalendar/list": "6.1.19", | ||||
|     "@fullcalendar/luxon3": "6.1.19", | ||||
|     "@fullcalendar/timegrid": "6.1.19", | ||||
|     "@home-assistant/webawesome": "3.0.0-beta.6.ha.1", | ||||
|     "@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", | ||||
| @@ -99,7 +99,7 @@ | ||||
|     "barcode-detector": "3.0.6", | ||||
|     "color-name": "2.0.2", | ||||
|     "comlink": "4.4.2", | ||||
|     "core-js": "3.45.1", | ||||
|     "core-js": "3.46.0", | ||||
|     "cropperjs": "1.6.2", | ||||
|     "culori": "4.0.2", | ||||
|     "date-fns": "4.1.0", | ||||
| @@ -114,7 +114,7 @@ | ||||
|     "hls.js": "1.6.13", | ||||
|     "home-assistant-js-websocket": "9.5.0", | ||||
|     "idb-keyval": "6.2.2", | ||||
|     "intl-messageformat": "10.7.17", | ||||
|     "intl-messageformat": "10.7.18", | ||||
|     "js-yaml": "4.1.0", | ||||
|     "leaflet": "1.9.4", | ||||
|     "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", | ||||
| @@ -122,7 +122,7 @@ | ||||
|     "lit": "3.3.1", | ||||
|     "lit-html": "3.3.1", | ||||
|     "luxon": "3.7.2", | ||||
|     "marked": "16.3.0", | ||||
|     "marked": "16.4.1", | ||||
|     "memoize-one": "6.0.0", | ||||
|     "node-vibrant": "4.0.3", | ||||
|     "object-hash": "3.0.0", | ||||
| @@ -135,7 +135,7 @@ | ||||
|     "stacktrace-js": "2.0.2", | ||||
|     "superstruct": "2.0.2", | ||||
|     "tinykeys": "3.0.0", | ||||
|     "ua-parser-js": "2.0.5", | ||||
|     "ua-parser-js": "2.0.6", | ||||
|     "vue": "2.7.16", | ||||
|     "vue2-daterange-picker": "0.6.8", | ||||
|     "weekstart": "2.0.0", | ||||
| @@ -148,16 +148,16 @@ | ||||
|     "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.4", | ||||
|     "@lokalise/node-api": "15.3.0", | ||||
|     "@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.3.1", | ||||
|     "@rsdoctor/rspack-plugin": "1.3.4", | ||||
|     "@rspack/core": "1.5.8", | ||||
|     "@rspack/dev-server": "1.1.4", | ||||
|     "@types/babel__plugin-transform-runtime": "7.9.5", | ||||
| @@ -167,30 +167,30 @@ | ||||
|     "@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.1", | ||||
|     "eslint": "9.37.0", | ||||
|     "eslint": "9.38.0", | ||||
|     "eslint-config-airbnb-base": "15.0.0", | ||||
|     "eslint-config-prettier": "10.1.8", | ||||
|     "eslint-import-resolver-webpack": "0.13.10", | ||||
|     "eslint-plugin-import": "2.32.0", | ||||
|     "eslint-plugin-lit": "2.1.1", | ||||
|     "eslint-plugin-lit-a11y": "5.1.1", | ||||
|     "eslint-plugin-unused-imports": "4.2.0", | ||||
|     "eslint-plugin-unused-imports": "4.3.0", | ||||
|     "eslint-plugin-wc": "3.0.2", | ||||
|     "fancy-log": "2.0.0", | ||||
|     "fs-extra": "11.3.2", | ||||
| @@ -201,9 +201,9 @@ | ||||
|     "gulp-rename": "2.1.0", | ||||
|     "html-minifier-terser": "7.2.0", | ||||
|     "husky": "9.1.7", | ||||
|     "jsdom": "27.0.0", | ||||
|     "jsdom": "27.0.1", | ||||
|     "jszip": "3.10.1", | ||||
|     "lint-staged": "16.2.3", | ||||
|     "lint-staged": "16.2.6", | ||||
|     "lit-analyzer": "2.0.3", | ||||
|     "lodash.merge": "4.6.2", | ||||
|     "lodash.template": "4.5.0", | ||||
| @@ -217,9 +217,9 @@ | ||||
|     "terser-webpack-plugin": "5.3.14", | ||||
|     "ts-lit-plugin": "2.0.2", | ||||
|     "typescript": "5.9.3", | ||||
|     "typescript-eslint": "8.45.0", | ||||
|     "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" | ||||
| @@ -235,5 +235,8 @@ | ||||
|     "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.10.3" | ||||
|   "packageManager": "yarn@4.10.3", | ||||
|   "volta": { | ||||
|     "node": "22.21.1" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" | ||||
|  | ||||
| [project] | ||||
| name         = "home-assistant-frontend" | ||||
| version      = "20250924.0" | ||||
| version      = "20251029.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"], | ||||
|   | ||||
| @@ -61,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; | ||||
| }; | ||||
| @@ -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, | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| 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"; | ||||
| import { computeEntityName } from "../entity/compute_entity_name"; | ||||
| import { computeDeviceName } from "../entity/compute_device_name"; | ||||
| import { getEntityContext } from "../entity/context/get_entity_context"; | ||||
| import { computeAreaName } from "../entity/compute_area_name"; | ||||
| import { computeFloorName } from "../entity/compute_floor_name"; | ||||
| import { ensureArray } from "../array/ensure-array"; | ||||
|  | ||||
| export type FormatEntityStateFunc = ( | ||||
|   stateObj: HassEntity, | ||||
| @@ -27,8 +26,8 @@ export type EntityNameType = "entity" | "device" | "area" | "floor"; | ||||
|  | ||||
| export type FormatEntityNameFunc = ( | ||||
|   stateObj: HassEntity, | ||||
|   type: EntityNameType | EntityNameType[], | ||||
|   separator?: string | ||||
|   name: EntityNameItem | EntityNameItem[], | ||||
|   options?: EntityNameOptions | ||||
| ) => string; | ||||
|  | ||||
| export const computeFormatFunctions = async ( | ||||
| @@ -75,45 +74,15 @@ export const computeFormatFunctions = async ( | ||||
|       ), | ||||
|     formatEntityAttributeName: (stateObj, attribute) => | ||||
|       computeAttributeNameDisplay(localize, stateObj, entities, attribute), | ||||
|     formatEntityName: (stateObj, type, separator = " ") => { | ||||
|       const types = ensureArray(type); | ||||
|       const namesList: (string | undefined)[] = []; | ||||
|  | ||||
|       const { device, area, floor } = getEntityContext( | ||||
|     formatEntityName: (stateObj, name, options) => | ||||
|       computeEntityNameDisplay( | ||||
|         stateObj, | ||||
|         name, | ||||
|         entities, | ||||
|         devices, | ||||
|         areas, | ||||
|         floors | ||||
|       ); | ||||
|  | ||||
|       for (const t of types) { | ||||
|         switch (t) { | ||||
|           case "entity": { | ||||
|             namesList.push(computeEntityName(stateObj, entities, devices)); | ||||
|             break; | ||||
|           } | ||||
|           case "device": { | ||||
|             if (device) { | ||||
|               namesList.push(computeDeviceName(device)); | ||||
|             } | ||||
|             break; | ||||
|           } | ||||
|           case "area": { | ||||
|             if (area) { | ||||
|               namesList.push(computeAreaName(area)); | ||||
|             } | ||||
|             break; | ||||
|           } | ||||
|           case "floor": { | ||||
|             if (floor) { | ||||
|               namesList.push(computeFloorName(floor)); | ||||
|             } | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       return namesList.filter((name) => name !== undefined).join(separator); | ||||
|     }, | ||||
|         floors, | ||||
|         options | ||||
|       ), | ||||
|   }; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										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 | ||||
|   | ||||
| @@ -22,11 +22,12 @@ import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { listenMediaQuery } from "../../common/dom/media_query"; | ||||
| import { themesContext } from "../../data/context"; | ||||
| import type { Themes } from "../../data/ws-themes"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { isMac } from "../../util/is_mac"; | ||||
| import "../chips/ha-assist-chip"; | ||||
| import "../ha-icon-button"; | ||||
| import { filterXSS } from "../../common/util/xss"; | ||||
| import { formatTimeLabel } from "./axis-label"; | ||||
| import { downSampleLineData } from "./down-sample"; | ||||
|  | ||||
| @@ -34,6 +35,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; | ||||
| const LEGEND_OVERFLOW_LIMIT = 10; | ||||
| const LEGEND_OVERFLOW_LIMIT_MOBILE = 6; | ||||
| const DOUBLE_TAP_TIME = 300; | ||||
| const RESIZE_ANIMATION_DURATION = 250; | ||||
|  | ||||
| export type CustomLegendOption = ECOption["legend"] & { | ||||
|   type: "custom"; | ||||
| @@ -87,9 +89,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; | ||||
| @@ -194,6 +206,15 @@ export class HaChartBase extends LitElement { | ||||
|     } | ||||
|     if (changedProps.has("options")) { | ||||
|       chartOptions = { ...chartOptions, ...this._createOptions() }; | ||||
|       if ( | ||||
|         this._compareCustomLegendOptions( | ||||
|           changedProps.get("options"), | ||||
|           this.options | ||||
|         ) | ||||
|       ) { | ||||
|         // custom legend changes may require a resize to layout properly | ||||
|         this._shouldResizeChart = true; | ||||
|       } | ||||
|     } else if (this._isTouchDevice && changedProps.has("_isZoomed")) { | ||||
|       chartOptions.dataZoom = this._getDataZoomConfig(); | ||||
|     } | ||||
| @@ -285,7 +306,7 @@ export class HaChartBase extends LitElement { | ||||
|           itemStyle = { | ||||
|             color: dataset?.color as string, | ||||
|             ...(dataset?.itemStyle as { borderColor?: string }), | ||||
|             itemStyle, | ||||
|             ...itemStyle, | ||||
|           }; | ||||
|           const color = itemStyle?.color as string; | ||||
|           const borderColor = itemStyle?.borderColor as string; | ||||
| @@ -345,7 +366,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); | ||||
| @@ -365,6 +386,7 @@ export class HaChartBase extends LitElement { | ||||
|       if (!this.options?.dataZoom) { | ||||
|         this.chart.getZr().on("dblclick", this._handleClickZoom); | ||||
|       } | ||||
|       this.chart.on("finished", this._handleChartRenderFinished); | ||||
|       if (this._isTouchDevice) { | ||||
|         this.chart.getZr().on("click", (e: ECElementEvent) => { | ||||
|           if (!e.zrByTouch) { | ||||
| @@ -496,6 +518,7 @@ export class HaChartBase extends LitElement { | ||||
|         ); | ||||
|       } | ||||
|     }); | ||||
|     this.requestUpdate("_hiddenDatasets"); | ||||
|   } | ||||
|  | ||||
|   private _getDataZoomConfig(): DataZoomComponentOption | undefined { | ||||
| @@ -804,14 +827,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"]; | ||||
|   } | ||||
| @@ -943,6 +967,33 @@ export class HaChartBase extends LitElement { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _handleChartRenderFinished = () => { | ||||
|     if (this._shouldResizeChart) { | ||||
|       this.chart?.resize({ | ||||
|         animation: this._reducedMotion | ||||
|           ? undefined | ||||
|           : { duration: RESIZE_ANIMATION_DURATION }, | ||||
|       }); | ||||
|       this._shouldResizeChart = false; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _compareCustomLegendOptions( | ||||
|     oldOptions: ECOption | undefined, | ||||
|     newOptions: ECOption | undefined | ||||
|   ): boolean { | ||||
|     const oldLegends = ensureArray( | ||||
|       oldOptions?.legend || [] | ||||
|     ) as LegendComponentOption[]; | ||||
|     const newLegends = ensureArray( | ||||
|       newOptions?.legend || [] | ||||
|     ) as LegendComponentOption[]; | ||||
|     return ( | ||||
|       oldLegends.some((l) => l.show && l.type === "custom") !== | ||||
|       newLegends.some((l) => l.show && l.type === "custom") | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; | ||||
| import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { listenMediaQuery } from "../../common/dom/media_query"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import "./ha-chart-base"; | ||||
| import type { HaChartBase } from "./ha-chart-base"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
|   | ||||
| @@ -1,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, | ||||
|   | ||||
| @@ -15,8 +15,8 @@ import type { TimelineEntity } from "../../data/history"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | ||||
| import { computeTimelineColor } from "./timeline-color"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import echarts from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import echarts from "../../resources/echarts/echarts"; | ||||
| import { luminosity } from "../../common/color/rgb"; | ||||
| import { hex2rgb } from "../../common/color/convert-color"; | ||||
| import { measureTextWidth } from "../../util/text"; | ||||
|   | ||||
| @@ -29,7 +29,7 @@ import { | ||||
|   getStatisticMetadata, | ||||
|   statisticsHaveType, | ||||
| } from "../../data/recorder"; | ||||
| import type { ECOption } from "../../resources/echarts"; | ||||
| import type { ECOption } from "../../resources/echarts/echarts"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import type { CustomLegendOption } from "./ha-chart-base"; | ||||
| import "./ha-chart-base"; | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles"; | ||||
| import { FilterChip } from "@material/web/chips/internal/filter-chip"; | ||||
| import { styles } from "@material/web/chips/internal/filter-styles"; | ||||
| import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles"; | ||||
| import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles"; | ||||
| import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles"; | ||||
| import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles"; | ||||
| import { css, html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
|  | ||||
| @@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip { | ||||
|           var(--rgb-primary-text-color), | ||||
|           0.15 | ||||
|         ); | ||||
|         border-radius: var(--ha-border-radius-md); | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||
| import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||
| import type { CSSResultGroup } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| @@ -129,7 +129,7 @@ export class DialogDataTableSettings extends LitElement { | ||||
|                   ${canMove && isVisible | ||||
|                     ? html`<ha-svg-icon | ||||
|                         class="handle" | ||||
|                         .path=${mdiDrag} | ||||
|                         .path=${mdiDragHorizontalVariant} | ||||
|                         slot="graphic" | ||||
|                       ></ha-svg-icon>` | ||||
|                     : nothing} | ||||
|   | ||||
| @@ -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 = ( | ||||
|   | ||||
| @@ -5,24 +5,18 @@ import { customElement, property, query, state } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { computeAreaName } from "../../common/entity/compute_area_name"; | ||||
| import { | ||||
|   computeDeviceName, | ||||
|   computeDeviceNameDisplay, | ||||
| } from "../../common/entity/compute_device_name"; | ||||
| import { computeDomain } from "../../common/entity/compute_domain"; | ||||
| import { computeDeviceName } from "../../common/entity/compute_device_name"; | ||||
| import { getDeviceContext } from "../../common/entity/context/get_device_context"; | ||||
| import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; | ||||
| import { | ||||
|   getDeviceEntityDisplayLookup, | ||||
|   type DeviceEntityDisplayLookup, | ||||
|   getDevices, | ||||
|   type DevicePickerItem, | ||||
|   type DeviceRegistryEntry, | ||||
| } from "../../data/device_registry"; | ||||
| import { domainToName } from "../../data/integration"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { brandsUrl } from "../../util/brands-url"; | ||||
| import "../ha-generic-picker"; | ||||
| import type { HaGenericPicker } from "../ha-generic-picker"; | ||||
| import type { PickerComboBoxItem } from "../ha-picker-combo-box"; | ||||
|  | ||||
| export type HaDevicePickerDeviceFilterFunc = ( | ||||
|   device: DeviceRegistryEntry | ||||
| @@ -30,11 +24,6 @@ export type HaDevicePickerDeviceFilterFunc = ( | ||||
|  | ||||
| export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; | ||||
|  | ||||
| interface DevicePickerItem extends PickerComboBoxItem { | ||||
|   domain?: string; | ||||
|   domain_name?: string; | ||||
| } | ||||
|  | ||||
| @customElement("ha-device-picker") | ||||
| export class HaDevicePicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
| @@ -104,6 +93,8 @@ export class HaDevicePicker extends LitElement { | ||||
|  | ||||
|   @state() private _configEntryLookup: Record<string, ConfigEntry> = {}; | ||||
|  | ||||
|   private _getDevicesMemoized = memoizeOne(getDevices); | ||||
|  | ||||
|   protected firstUpdated(_changedProperties: PropertyValues): void { | ||||
|     super.firstUpdated(_changedProperties); | ||||
|     this._loadConfigEntries(); | ||||
| @@ -117,162 +108,18 @@ export class HaDevicePicker extends LitElement { | ||||
|   } | ||||
|  | ||||
|   private _getItems = () => | ||||
|     this._getDevices( | ||||
|       this.hass.devices, | ||||
|       this.hass.entities, | ||||
|     this._getDevicesMemoized( | ||||
|       this.hass, | ||||
|       this._configEntryLookup, | ||||
|       this.includeDomains, | ||||
|       this.excludeDomains, | ||||
|       this.includeDeviceClasses, | ||||
|       this.deviceFilter, | ||||
|       this.entityFilter, | ||||
|       this.excludeDevices | ||||
|       this.excludeDevices, | ||||
|       this.value | ||||
|     ); | ||||
|  | ||||
|   private _getDevices = memoizeOne( | ||||
|     ( | ||||
|       haDevices: HomeAssistant["devices"], | ||||
|       haEntities: HomeAssistant["entities"], | ||||
|       configEntryLookup: Record<string, ConfigEntry>, | ||||
|       includeDomains: this["includeDomains"], | ||||
|       excludeDomains: this["excludeDomains"], | ||||
|       includeDeviceClasses: this["includeDeviceClasses"], | ||||
|       deviceFilter: this["deviceFilter"], | ||||
|       entityFilter: this["entityFilter"], | ||||
|       excludeDevices: this["excludeDevices"] | ||||
|     ): DevicePickerItem[] => { | ||||
|       const devices = Object.values(haDevices); | ||||
|       const entities = Object.values(haEntities); | ||||
|  | ||||
|       let deviceEntityLookup: DeviceEntityDisplayLookup = {}; | ||||
|  | ||||
|       if ( | ||||
|         includeDomains || | ||||
|         excludeDomains || | ||||
|         includeDeviceClasses || | ||||
|         entityFilter | ||||
|       ) { | ||||
|         deviceEntityLookup = getDeviceEntityDisplayLookup(entities); | ||||
|       } | ||||
|  | ||||
|       let inputDevices = devices.filter( | ||||
|         (device) => device.id === this.value || !device.disabled_by | ||||
|       ); | ||||
|  | ||||
|       if (includeDomains) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return false; | ||||
|           } | ||||
|           return deviceEntityLookup[device.id].some((entity) => | ||||
|             includeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (excludeDomains) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return true; | ||||
|           } | ||||
|           return entities.every( | ||||
|             (entity) => | ||||
|               !excludeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (excludeDevices) { | ||||
|         inputDevices = inputDevices.filter( | ||||
|           (device) => !excludeDevices!.includes(device.id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (includeDeviceClasses) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return false; | ||||
|           } | ||||
|           return deviceEntityLookup[device.id].some((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             if (!stateObj) { | ||||
|               return false; | ||||
|             } | ||||
|             return ( | ||||
|               stateObj.attributes.device_class && | ||||
|               includeDeviceClasses.includes(stateObj.attributes.device_class) | ||||
|             ); | ||||
|           }); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (entityFilter) { | ||||
|         inputDevices = inputDevices.filter((device) => { | ||||
|           const devEntities = deviceEntityLookup[device.id]; | ||||
|           if (!devEntities || !devEntities.length) { | ||||
|             return false; | ||||
|           } | ||||
|           return devEntities.some((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             if (!stateObj) { | ||||
|               return false; | ||||
|             } | ||||
|             return entityFilter(stateObj); | ||||
|           }); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (deviceFilter) { | ||||
|         inputDevices = inputDevices.filter( | ||||
|           (device) => | ||||
|             // We always want to include the device of the current value | ||||
|             device.id === this.value || deviceFilter!(device) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       const outputDevices = inputDevices.map<DevicePickerItem>((device) => { | ||||
|         const deviceName = computeDeviceNameDisplay( | ||||
|           device, | ||||
|           this.hass, | ||||
|           deviceEntityLookup[device.id] | ||||
|         ); | ||||
|  | ||||
|         const { area } = getDeviceContext(device, this.hass); | ||||
|  | ||||
|         const areaName = area ? computeAreaName(area) : undefined; | ||||
|  | ||||
|         const configEntry = device.primary_config_entry | ||||
|           ? configEntryLookup?.[device.primary_config_entry] | ||||
|           : undefined; | ||||
|  | ||||
|         const domain = configEntry?.domain; | ||||
|         const domainName = domain | ||||
|           ? domainToName(this.hass.localize, domain) | ||||
|           : undefined; | ||||
|  | ||||
|         return { | ||||
|           id: device.id, | ||||
|           label: "", | ||||
|           primary: | ||||
|             deviceName || | ||||
|             this.hass.localize("ui.components.device-picker.unnamed_device"), | ||||
|           secondary: areaName, | ||||
|           domain: configEntry?.domain, | ||||
|           domain_name: domainName, | ||||
|           search_labels: [deviceName, areaName, domain, domainName].filter( | ||||
|             Boolean | ||||
|           ) as string[], | ||||
|           sorting_label: deviceName || "zzz", | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|       return outputDevices; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _valueRenderer = memoizeOne( | ||||
|     (configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => { | ||||
|       const deviceId = value; | ||||
|   | ||||
							
								
								
									
										1
									
								
								src/components/entity/const.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/entity/const.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__"; | ||||
| @@ -1,13 +1,13 @@ | ||||
| import { mdiDrag } from "@mdi/js"; | ||||
| import { mdiDragHorizontalVariant } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { isValidEntityId } from "../../common/entity/valid_entity_id"; | ||||
| import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../../types"; | ||||
| import "../ha-sortable"; | ||||
| import "./ha-entity-picker"; | ||||
| import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker"; | ||||
|  | ||||
| @customElement("ha-entities-picker") | ||||
| class HaEntitiesPicker extends LitElement { | ||||
| @@ -118,7 +118,7 @@ class HaEntitiesPicker extends LitElement { | ||||
|                   ? html` | ||||
|                       <ha-svg-icon | ||||
|                         class="entity-handle" | ||||
|                         .path=${mdiDrag} | ||||
|                         .path=${mdiDragHorizontalVariant} | ||||
|                       ></ha-svg-icon> | ||||
|                     ` | ||||
|                   : nothing} | ||||
| @@ -147,6 +147,7 @@ class HaEntitiesPicker extends LitElement { | ||||
|           .createDomains=${this.createDomains} | ||||
|           .required=${this.required && !currentEntities.length} | ||||
|           @value-changed=${this._addEntity} | ||||
|           add-button | ||||
|         ></ha-entity-picker> | ||||
|       </div> | ||||
|     `; | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { LitElement, html, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| @@ -8,8 +7,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; | ||||
| import "../ha-combo-box"; | ||||
| import type { HaComboBox } from "../ha-combo-box"; | ||||
|  | ||||
| export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; | ||||
|  | ||||
| interface AttributeOption { | ||||
|   value: string; | ||||
|   label: string; | ||||
|   | ||||
							
								
								
									
										536
									
								
								src/components/entity/ha-entity-name-picker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										536
									
								
								src/components/entity/ha-entity-name-picker.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,536 @@ | ||||
| import "@material/mwc-menu/mwc-menu-surface"; | ||||
| import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import type { IFuseOptions } from "fuse.js"; | ||||
| import Fuse from "fuse.js"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { stopPropagation } from "../../common/dom/stop_propagation"; | ||||
| import type { EntityNameItem } from "../../common/entity/compute_entity_name_display"; | ||||
| import { getEntityContext } from "../../common/entity/context/get_entity_context"; | ||||
| import type { EntityNameType } from "../../common/translations/entity-state"; | ||||
| import type { LocalizeKeys } from "../../common/translations/localize"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../../types"; | ||||
| import "../chips/ha-assist-chip"; | ||||
| import "../chips/ha-chip-set"; | ||||
| import "../chips/ha-input-chip"; | ||||
| import "../ha-combo-box"; | ||||
| import type { HaComboBox } from "../ha-combo-box"; | ||||
| import "../ha-input-helper-text"; | ||||
| import "../ha-sortable"; | ||||
|  | ||||
| interface EntityNameOption { | ||||
|   primary: string; | ||||
|   secondary?: string; | ||||
|   field_label: string; | ||||
|   value: string; | ||||
| } | ||||
|  | ||||
| const rowRenderer: ComboBoxLitRenderer<EntityNameOption> = (item) => html` | ||||
|   <ha-combo-box-item type="button"> | ||||
|     <span slot="headline">${item.primary}</span> | ||||
|     ${item.secondary | ||||
|       ? html`<span slot="supporting-text">${item.secondary}</span>` | ||||
|       : nothing} | ||||
|   </ha-combo-box-item> | ||||
| `; | ||||
|  | ||||
| const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]); | ||||
|  | ||||
| const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]); | ||||
|  | ||||
| const formatOptionValue = (item: EntityNameItem) => { | ||||
|   if (item.type === "text" && item.text) { | ||||
|     return item.text; | ||||
|   } | ||||
|   return `___${item.type}___`; | ||||
| }; | ||||
|  | ||||
| const parseOptionValue = (value: string): EntityNameItem => { | ||||
|   if (value.startsWith("___") && value.endsWith("___")) { | ||||
|     const type = value.slice(3, -3); | ||||
|     if (KNOWN_TYPES.has(type)) { | ||||
|       return { type: type as EntityNameType }; | ||||
|     } | ||||
|   } | ||||
|   return { type: "text", text: value }; | ||||
| }; | ||||
|  | ||||
| @customElement("ha-entity-name-picker") | ||||
| export class HaEntityNamePicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: false }) public entityId?: string; | ||||
|  | ||||
|   @property({ attribute: false }) public value?: | ||||
|     | string | ||||
|     | EntityNameItem | ||||
|     | EntityNameItem[]; | ||||
|  | ||||
|   @property() public label?: string; | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = false; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true }) public disabled = false; | ||||
|  | ||||
|   @query(".container", true) private _container?: HTMLDivElement; | ||||
|  | ||||
|   @query("ha-combo-box", true) private _comboBox!: HaComboBox; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|  | ||||
|   private _editIndex?: number; | ||||
|  | ||||
|   private _validTypes = memoizeOne((entityId?: string) => { | ||||
|     const options = new Set<string>(["text"]); | ||||
|     if (!entityId) { | ||||
|       return options; | ||||
|     } | ||||
|  | ||||
|     const stateObj = this.hass.states[entityId]; | ||||
|  | ||||
|     if (!stateObj) { | ||||
|       return options; | ||||
|     } | ||||
|  | ||||
|     options.add("entity"); | ||||
|  | ||||
|     const context = getEntityContext( | ||||
|       stateObj, | ||||
|       this.hass.entities, | ||||
|       this.hass.devices, | ||||
|       this.hass.areas, | ||||
|       this.hass.floors | ||||
|     ); | ||||
|  | ||||
|     if (context.device) options.add("device"); | ||||
|     if (context.area) options.add("area"); | ||||
|     if (context.floor) options.add("floor"); | ||||
|     return options; | ||||
|   }); | ||||
|  | ||||
|   private _getOptions = memoizeOne((entityId?: string) => { | ||||
|     if (!entityId) { | ||||
|       return []; | ||||
|     } | ||||
|  | ||||
|     const types = this._validTypes(entityId); | ||||
|  | ||||
|     const items = ( | ||||
|       ["entity", "device", "area", "floor"] as const | ||||
|     ).map<EntityNameOption>((name) => { | ||||
|       const stateObj = this.hass.states[entityId]; | ||||
|       const isValid = types.has(name); | ||||
|       const primary = this.hass.localize( | ||||
|         `ui.components.entity.entity-name-picker.types.${name}` | ||||
|       ); | ||||
|       const secondary = | ||||
|         (stateObj && isValid | ||||
|           ? this.hass.formatEntityName(stateObj, { type: name }) | ||||
|           : this.hass.localize( | ||||
|               `ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys | ||||
|             )) || "-"; | ||||
|  | ||||
|       return { | ||||
|         primary, | ||||
|         secondary, | ||||
|         field_label: primary, | ||||
|         value: formatOptionValue({ type: name }), | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     return items; | ||||
|   }); | ||||
|  | ||||
|   private _customNameOption = memoizeOne((text: string) => ({ | ||||
|     primary: this.hass.localize( | ||||
|       "ui.components.entity.entity-name-picker.custom_name" | ||||
|     ), | ||||
|     secondary: `"${text}"`, | ||||
|     field_label: text, | ||||
|     value: formatOptionValue({ type: "text", text }), | ||||
|   })); | ||||
|  | ||||
|   private _formatItem = (item: EntityNameItem) => { | ||||
|     if (item.type === "text") { | ||||
|       return `"${item.text}"`; | ||||
|     } | ||||
|     if (KNOWN_TYPES.has(item.type)) { | ||||
|       return this.hass.localize( | ||||
|         `ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}` | ||||
|       ); | ||||
|     } | ||||
|     return item.type; | ||||
|   }; | ||||
|  | ||||
|   protected render() { | ||||
|     const value = this._items; | ||||
|     const options = this._getOptions(this.entityId); | ||||
|     const validTypes = this._validTypes(this.entityId); | ||||
|  | ||||
|     return html` | ||||
|       ${this.label ? html`<label>${this.label}</label>` : nothing} | ||||
|       <div class="container"> | ||||
|         <ha-sortable | ||||
|           no-style | ||||
|           @item-moved=${this._moveItem} | ||||
|           .disabled=${this.disabled} | ||||
|           handle-selector="button.primary.action" | ||||
|           filter=".add" | ||||
|         > | ||||
|           <ha-chip-set> | ||||
|             ${repeat( | ||||
|               this._items, | ||||
|               (item) => item, | ||||
|               (item: EntityNameItem, idx) => { | ||||
|                 const label = this._formatItem(item); | ||||
|                 const isValid = validTypes.has(item.type); | ||||
|                 return html` | ||||
|                   <ha-input-chip | ||||
|                     data-idx=${idx} | ||||
|                     @remove=${this._removeItem} | ||||
|                     @click=${this._editItem} | ||||
|                     .label=${label} | ||||
|                     .selected=${!this.disabled} | ||||
|                     .disabled=${this.disabled} | ||||
|                     class=${!isValid ? "invalid" : ""} | ||||
|                   > | ||||
|                     <ha-svg-icon | ||||
|                       slot="icon" | ||||
|                       .path=${mdiDragHorizontalVariant} | ||||
|                     ></ha-svg-icon> | ||||
|                     <span>${label}</span> | ||||
|                   </ha-input-chip> | ||||
|                 `; | ||||
|               } | ||||
|             )} | ||||
|             ${this.disabled | ||||
|               ? nothing | ||||
|               : html` | ||||
|                   <ha-assist-chip | ||||
|                     @click=${this._addItem} | ||||
|                     .disabled=${this.disabled} | ||||
|                     label=${this.hass.localize( | ||||
|                       "ui.components.entity.entity-name-picker.add" | ||||
|                     )} | ||||
|                     class="add" | ||||
|                   > | ||||
|                     <ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon> | ||||
|                   </ha-assist-chip> | ||||
|                 `} | ||||
|           </ha-chip-set> | ||||
|         </ha-sortable> | ||||
|  | ||||
|         <mwc-menu-surface | ||||
|           .open=${this._opened} | ||||
|           @closed=${this._onClosed} | ||||
|           @opened=${this._onOpened} | ||||
|           @input=${stopPropagation} | ||||
|           .anchor=${this._container} | ||||
|         > | ||||
|           <ha-combo-box | ||||
|             .hass=${this.hass} | ||||
|             .value=${""} | ||||
|             .autofocus=${this.autofocus} | ||||
|             .disabled=${this.disabled} | ||||
|             .required=${this.required && !value.length} | ||||
|             .items=${options} | ||||
|             allow-custom-value | ||||
|             item-id-path="value" | ||||
|             item-value-path="value" | ||||
|             item-label-path="field_label" | ||||
|             .renderer=${rowRenderer} | ||||
|             @opened-changed=${this._openedChanged} | ||||
|             @value-changed=${this._comboBoxValueChanged} | ||||
|             @filter-changed=${this._filterChanged} | ||||
|           > | ||||
|           </ha-combo-box> | ||||
|         </mwc-menu-surface> | ||||
|       </div> | ||||
|       ${this._renderHelper()} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderHelper() { | ||||
|     return this.helper | ||||
|       ? html` | ||||
|           <ha-input-helper-text .disabled=${this.disabled}> | ||||
|             ${this.helper} | ||||
|           </ha-input-helper-text> | ||||
|         ` | ||||
|       : nothing; | ||||
|   } | ||||
|  | ||||
|   private _onClosed(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     this._opened = false; | ||||
|     this._editIndex = undefined; | ||||
|   } | ||||
|  | ||||
|   private async _onOpened(ev) { | ||||
|     if (!this._opened) { | ||||
|       return; | ||||
|     } | ||||
|     ev.stopPropagation(); | ||||
|     this._opened = true; | ||||
|     await this._comboBox?.focus(); | ||||
|     await this._comboBox?.open(); | ||||
|   } | ||||
|  | ||||
|   private async _addItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   private async _editItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     const idx = parseInt(ev.currentTarget.dataset.idx, 10); | ||||
|     this._editIndex = idx; | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   private get _items(): EntityNameItem[] { | ||||
|     return this._toItems(this.value); | ||||
|   } | ||||
|  | ||||
|   private _toItems = memoizeOne((value?: typeof this.value) => { | ||||
|     if (typeof value === "string") { | ||||
|       if (value === "") { | ||||
|         return []; | ||||
|       } | ||||
|       return [{ type: "text", text: value } satisfies EntityNameItem]; | ||||
|     } | ||||
|     return value ? ensureArray(value) : []; | ||||
|   }); | ||||
|  | ||||
|   private _toValue = memoizeOne( | ||||
|     (items: EntityNameItem[]): typeof this.value => { | ||||
|       if (items.length === 0) { | ||||
|         return undefined; | ||||
|       } | ||||
|       if (items.length === 1) { | ||||
|         const item = items[0]; | ||||
|         return item.type === "text" ? item.text : item; | ||||
|       } | ||||
|       return items; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _openedChanged(ev: ValueChangedEvent<boolean>) { | ||||
|     const open = ev.detail.value; | ||||
|     if (open) { | ||||
|       const options = this._comboBox.items || []; | ||||
|  | ||||
|       const initialItem = | ||||
|         this._editIndex != null ? this._items[this._editIndex] : undefined; | ||||
|  | ||||
|       const initialValue = initialItem ? formatOptionValue(initialItem) : ""; | ||||
|  | ||||
|       const filteredItems = this._filterSelectedOptions(options, initialValue); | ||||
|  | ||||
|       if (initialItem?.type === "text" && initialItem.text) { | ||||
|         filteredItems.push(this._customNameOption(initialItem.text)); | ||||
|       } | ||||
|  | ||||
|       this._comboBox.filteredItems = filteredItems; | ||||
|       this._comboBox.setInputValue(initialValue); | ||||
|     } else { | ||||
|       this._opened = false; | ||||
|       this._comboBox.setInputValue(""); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _filterSelectedOptions = ( | ||||
|     options: EntityNameOption[], | ||||
|     current?: string | ||||
|   ) => { | ||||
|     const items = this._items; | ||||
|  | ||||
|     const excludedValues = new Set( | ||||
|       items | ||||
|         .filter((item) => UNIQUE_TYPES.has(item.type)) | ||||
|         .map((item) => formatOptionValue(item)) | ||||
|     ); | ||||
|  | ||||
|     const filteredOptions = options.filter( | ||||
|       (option) => !excludedValues.has(option.value) || option.value === current | ||||
|     ); | ||||
|     return filteredOptions; | ||||
|   }; | ||||
|  | ||||
|   private _filterChanged(ev: ValueChangedEvent<string>) { | ||||
|     const input = ev.detail.value; | ||||
|     const filter = input?.toLowerCase() || ""; | ||||
|     const options = this._comboBox.items || []; | ||||
|  | ||||
|     const currentItem = | ||||
|       this._editIndex != null ? this._items[this._editIndex] : undefined; | ||||
|  | ||||
|     const currentValue = currentItem ? formatOptionValue(currentItem) : ""; | ||||
|  | ||||
|     let filteredItems = this._filterSelectedOptions(options, currentValue); | ||||
|  | ||||
|     if (!filter) { | ||||
|       this._comboBox.filteredItems = filteredItems; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const fuseOptions: IFuseOptions<EntityNameOption> = { | ||||
|       keys: ["primary", "secondary", "value"], | ||||
|       isCaseSensitive: false, | ||||
|       minMatchCharLength: Math.min(filter.length, 2), | ||||
|       threshold: 0.2, | ||||
|       ignoreDiacritics: true, | ||||
|     }; | ||||
|  | ||||
|     const fuse = new Fuse(filteredItems, fuseOptions); | ||||
|     filteredItems = fuse.search(filter).map((result) => result.item); | ||||
|     filteredItems.push(this._customNameOption(input)); | ||||
|     this._comboBox.filteredItems = filteredItems; | ||||
|   } | ||||
|  | ||||
|   private async _moveItem(ev: CustomEvent) { | ||||
|     ev.stopPropagation(); | ||||
|     const { oldIndex, newIndex } = ev.detail; | ||||
|     const value = this._items; | ||||
|     const newValue = value.concat(); | ||||
|     const element = newValue.splice(oldIndex, 1)[0]; | ||||
|     newValue.splice(newIndex, 0, element); | ||||
|     this._setValue(newValue); | ||||
|     await this.updateComplete; | ||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); | ||||
|   } | ||||
|  | ||||
|   private async _removeItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     const value = [...this._items]; | ||||
|     const idx = parseInt(ev.target.dataset.idx, 10); | ||||
|     value.splice(idx, 1); | ||||
|     this._setValue(value); | ||||
|     await this.updateComplete; | ||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); | ||||
|   } | ||||
|  | ||||
|   private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void { | ||||
|     ev.stopPropagation(); | ||||
|     const value = ev.detail.value; | ||||
|  | ||||
|     if (this.disabled || value === "") { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const item: EntityNameItem = parseOptionValue(value); | ||||
|  | ||||
|     const newValue = [...this._items]; | ||||
|  | ||||
|     if (this._editIndex != null) { | ||||
|       newValue[this._editIndex] = item; | ||||
|     } else { | ||||
|       newValue.push(item); | ||||
|     } | ||||
|  | ||||
|     this._setValue(newValue); | ||||
|   } | ||||
|  | ||||
|   private _setValue(value: EntityNameItem[]) { | ||||
|     const newValue = this._toValue(value); | ||||
|     this.value = newValue; | ||||
|     fireEvent(this, "value-changed", { | ||||
|       value: newValue, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       position: relative; | ||||
|       width: 100%; | ||||
|     } | ||||
|  | ||||
|     .container { | ||||
|       position: relative; | ||||
|       background-color: var(--mdc-text-field-fill-color, whitesmoke); | ||||
|       border-radius: var(--ha-border-radius-sm); | ||||
|       border-end-end-radius: var(--ha-border-radius-square); | ||||
|       border-end-start-radius: var(--ha-border-radius-square); | ||||
|     } | ||||
|     .container:after { | ||||
|       display: block; | ||||
|       content: ""; | ||||
|       position: absolute; | ||||
|       pointer-events: none; | ||||
|       bottom: 0; | ||||
|       left: 0; | ||||
|       right: 0; | ||||
|       height: 1px; | ||||
|       width: 100%; | ||||
|       background-color: var( | ||||
|         --mdc-text-field-idle-line-color, | ||||
|         rgba(0, 0, 0, 0.42) | ||||
|       ); | ||||
|       transform: | ||||
|         height 180ms ease-in-out, | ||||
|         background-color 180ms ease-in-out; | ||||
|     } | ||||
|     :host([disabled]) .container:after { | ||||
|       background-color: var( | ||||
|         --mdc-text-field-disabled-line-color, | ||||
|         rgba(0, 0, 0, 0.42) | ||||
|       ); | ||||
|     } | ||||
|     .container:focus-within:after { | ||||
|       height: 2px; | ||||
|       background-color: var(--mdc-theme-primary); | ||||
|     } | ||||
|  | ||||
|     label { | ||||
|       display: block; | ||||
|       margin: 0 0 var(--ha-space-2); | ||||
|     } | ||||
|  | ||||
|     .add { | ||||
|       order: 1; | ||||
|     } | ||||
|  | ||||
|     mwc-menu-surface { | ||||
|       --mdc-menu-min-width: 100%; | ||||
|     } | ||||
|  | ||||
|     ha-chip-set { | ||||
|       padding: var(--ha-space-2) var(--ha-space-2); | ||||
|     } | ||||
|  | ||||
|     .invalid { | ||||
|       text-decoration: line-through; | ||||
|     } | ||||
|  | ||||
|     .sortable-fallback { | ||||
|       display: none; | ||||
|       opacity: 0; | ||||
|     } | ||||
|  | ||||
|     .sortable-ghost { | ||||
|       opacity: 0.4; | ||||
|     } | ||||
|  | ||||
|     .sortable-drag { | ||||
|       cursor: grabbing; | ||||
|     } | ||||
|  | ||||
|     ha-input-helper-text { | ||||
|       display: block; | ||||
|       margin: var(--ha-space-2) 0 0; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-entity-name-picker": HaEntityNamePicker; | ||||
|   } | ||||
| } | ||||
| @@ -1,14 +1,17 @@ | ||||
| import { mdiPlus, mdiShape } from "@mdi/js"; | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import { html, LitElement, nothing, type PropertyValues } from "lit"; | ||||
| import { customElement, property, query } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { computeDomain } from "../../common/entity/compute_domain"; | ||||
| import { computeStateName } from "../../common/entity/compute_state_name"; | ||||
| import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; | ||||
| import { isValidEntityId } from "../../common/entity/valid_entity_id"; | ||||
| import { computeRTL } from "../../common/util/compute_rtl"; | ||||
| import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; | ||||
| import { | ||||
|   getEntities, | ||||
|   type EntityComboBoxItem, | ||||
| } from "../../data/entity_registry"; | ||||
| import { domainToName } from "../../data/integration"; | ||||
| import { | ||||
|   isHelperDomain, | ||||
| @@ -19,21 +22,11 @@ import type { HomeAssistant } from "../../types"; | ||||
| import "../ha-combo-box-item"; | ||||
| import "../ha-generic-picker"; | ||||
| import type { HaGenericPicker } from "../ha-generic-picker"; | ||||
| import type { | ||||
|   PickerComboBoxItem, | ||||
|   PickerComboBoxSearchFn, | ||||
| } from "../ha-picker-combo-box"; | ||||
| import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box"; | ||||
| import type { PickerValueRenderer } from "../ha-picker-field"; | ||||
| import "../ha-svg-icon"; | ||||
| import "./state-badge"; | ||||
|  | ||||
| interface EntityComboBoxItem extends PickerComboBoxItem { | ||||
|   domain_name?: string; | ||||
|   stateObj?: HassEntity; | ||||
| } | ||||
|  | ||||
| export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; | ||||
|  | ||||
| const CREATE_ID = "___create-new-entity___"; | ||||
|  | ||||
| @customElement("ha-entity-picker") | ||||
| @@ -120,6 +113,9 @@ export class HaEntityPicker extends LitElement { | ||||
|   @property({ attribute: "hide-clear-icon", type: Boolean }) | ||||
|   public hideClearIcon = false; | ||||
|  | ||||
|   @property({ attribute: "add-button", type: Boolean }) | ||||
|   public addButton = false; | ||||
|  | ||||
|   @query("ha-generic-picker") private _picker?: HaGenericPicker; | ||||
|  | ||||
|   protected firstUpdated(changedProperties: PropertyValues): void { | ||||
| @@ -144,9 +140,14 @@ export class HaEntityPicker extends LitElement { | ||||
|       `; | ||||
|     } | ||||
|  | ||||
|     const entityName = this.hass.formatEntityName(stateObj, "entity"); | ||||
|     const deviceName = this.hass.formatEntityName(stateObj, "device"); | ||||
|     const areaName = this.hass.formatEntityName(stateObj, "area"); | ||||
|     const [entityName, deviceName, areaName] = computeEntityNameList( | ||||
|       stateObj, | ||||
|       [{ type: "entity" }, { type: "device" }, { type: "area" }], | ||||
|       this.hass.entities, | ||||
|       this.hass.devices, | ||||
|       this.hass.areas, | ||||
|       this.hass.floors | ||||
|     ); | ||||
|  | ||||
|     const isRTL = computeRTL(this.hass); | ||||
|  | ||||
| @@ -249,8 +250,10 @@ export class HaEntityPicker extends LitElement { | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _getEntitiesMemoized = memoizeOne(getEntities); | ||||
|  | ||||
|   private _getItems = () => | ||||
|     this._getEntities( | ||||
|     this._getEntitiesMemoized( | ||||
|       this.hass, | ||||
|       this.includeDomains, | ||||
|       this.excludeDomains, | ||||
| @@ -258,125 +261,10 @@ export class HaEntityPicker extends LitElement { | ||||
|       this.includeDeviceClasses, | ||||
|       this.includeUnitOfMeasurement, | ||||
|       this.includeEntities, | ||||
|       this.excludeEntities | ||||
|       this.excludeEntities, | ||||
|       this.value | ||||
|     ); | ||||
|  | ||||
|   private _getEntities = memoizeOne( | ||||
|     ( | ||||
|       hass: this["hass"], | ||||
|       includeDomains: this["includeDomains"], | ||||
|       excludeDomains: this["excludeDomains"], | ||||
|       entityFilter: this["entityFilter"], | ||||
|       includeDeviceClasses: this["includeDeviceClasses"], | ||||
|       includeUnitOfMeasurement: this["includeUnitOfMeasurement"], | ||||
|       includeEntities: this["includeEntities"], | ||||
|       excludeEntities: this["excludeEntities"] | ||||
|     ): EntityComboBoxItem[] => { | ||||
|       let items: EntityComboBoxItem[] = []; | ||||
|  | ||||
|       let entityIds = Object.keys(hass.states); | ||||
|  | ||||
|       if (includeEntities) { | ||||
|         entityIds = entityIds.filter((entityId) => | ||||
|           includeEntities.includes(entityId) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (excludeEntities) { | ||||
|         entityIds = entityIds.filter( | ||||
|           (entityId) => !excludeEntities.includes(entityId) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (includeDomains) { | ||||
|         entityIds = entityIds.filter((eid) => | ||||
|           includeDomains.includes(computeDomain(eid)) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (excludeDomains) { | ||||
|         entityIds = entityIds.filter( | ||||
|           (eid) => !excludeDomains.includes(computeDomain(eid)) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       const isRTL = computeRTL(this.hass); | ||||
|  | ||||
|       items = entityIds.map<EntityComboBoxItem>((entityId) => { | ||||
|         const stateObj = hass!.states[entityId]; | ||||
|  | ||||
|         const friendlyName = computeStateName(stateObj); // Keep this for search | ||||
|         const entityName = this.hass.formatEntityName(stateObj, "entity"); | ||||
|         const deviceName = this.hass.formatEntityName(stateObj, "device"); | ||||
|         const areaName = this.hass.formatEntityName(stateObj, "area"); | ||||
|  | ||||
|         const domainName = domainToName( | ||||
|           this.hass.localize, | ||||
|           computeDomain(entityId) | ||||
|         ); | ||||
|  | ||||
|         const primary = entityName || deviceName || entityId; | ||||
|         const secondary = [areaName, entityName ? deviceName : undefined] | ||||
|           .filter(Boolean) | ||||
|           .join(isRTL ? " ◂ " : " ▸ "); | ||||
|         const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); | ||||
|  | ||||
|         return { | ||||
|           id: entityId, | ||||
|           primary: primary, | ||||
|           secondary: secondary, | ||||
|           domain_name: domainName, | ||||
|           sorting_label: [deviceName, entityName].filter(Boolean).join("_"), | ||||
|           search_labels: [ | ||||
|             entityName, | ||||
|             deviceName, | ||||
|             areaName, | ||||
|             domainName, | ||||
|             friendlyName, | ||||
|             entityId, | ||||
|           ].filter(Boolean) as string[], | ||||
|           a11y_label: a11yLabel, | ||||
|           stateObj: stateObj, | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|       if (includeDeviceClasses) { | ||||
|         items = items.filter( | ||||
|           (item) => | ||||
|             // We always want to include the entity of the current value | ||||
|             item.id === this.value || | ||||
|             (item.stateObj?.attributes.device_class && | ||||
|               includeDeviceClasses.includes( | ||||
|                 item.stateObj.attributes.device_class | ||||
|               )) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (includeUnitOfMeasurement) { | ||||
|         items = items.filter( | ||||
|           (item) => | ||||
|             // We always want to include the entity of the current value | ||||
|             item.id === this.value || | ||||
|             (item.stateObj?.attributes.unit_of_measurement && | ||||
|               includeUnitOfMeasurement.includes( | ||||
|                 item.stateObj.attributes.unit_of_measurement | ||||
|               )) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (entityFilter) { | ||||
|         items = items.filter( | ||||
|           (item) => | ||||
|             // We always want to include the entity of the current value | ||||
|             item.id === this.value || | ||||
|             (item.stateObj && entityFilter!(item.stateObj)) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return items; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   protected render() { | ||||
|     const placeholder = | ||||
|       this.placeholder ?? | ||||
| @@ -396,7 +284,7 @@ export class HaEntityPicker extends LitElement { | ||||
|         .searchLabel=${this.searchLabel} | ||||
|         .notFoundLabel=${notFoundLabel} | ||||
|         .placeholder=${placeholder} | ||||
|         .value=${this.value} | ||||
|         .value=${this.addButton ? undefined : this.value} | ||||
|         .rowRenderer=${this._rowRenderer} | ||||
|         .getItems=${this._getItems} | ||||
|         .getAdditionalItems=${this._getAdditionalItems} | ||||
| @@ -404,6 +292,9 @@ export class HaEntityPicker extends LitElement { | ||||
|         .searchFn=${this._searchFn} | ||||
|         .valueRenderer=${this._valueRenderer} | ||||
|         @value-changed=${this._valueChanged} | ||||
|         .addButtonLabel=${this.addButton | ||||
|           ? this.hass.localize("ui.components.entity.entity-picker.add") | ||||
|           : undefined} | ||||
|       > | ||||
|       </ha-generic-picker> | ||||
|     `; | ||||
|   | ||||
| @@ -1,23 +1,39 @@ | ||||
| import { mdiDrag } from "@mdi/js"; | ||||
| import "@material/mwc-menu/mwc-menu-surface"; | ||||
| import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import type { IFuseOptions } from "fuse.js"; | ||||
| import Fuse from "fuse.js"; | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { stopPropagation } from "../../common/dom/stop_propagation"; | ||||
| import { computeDomain } from "../../common/entity/compute_domain"; | ||||
| import { | ||||
|   STATE_DISPLAY_SPECIAL_CONTENT, | ||||
|   STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS, | ||||
| } from "../../state-display/state-display"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../../types"; | ||||
| import "../ha-combo-box"; | ||||
| import "../ha-sortable"; | ||||
| import "../chips/ha-input-chip"; | ||||
| import "../chips/ha-assist-chip"; | ||||
| import "../chips/ha-chip-set"; | ||||
| import "../chips/ha-input-chip"; | ||||
| import "../ha-combo-box"; | ||||
| import type { HaComboBox } from "../ha-combo-box"; | ||||
| import "../ha-sortable"; | ||||
|  | ||||
| interface StateContentOption { | ||||
|   primary: string; | ||||
|   value: string; | ||||
| } | ||||
|  | ||||
| const rowRenderer: ComboBoxLitRenderer<StateContentOption> = (item) => html` | ||||
|   <ha-combo-box-item type="button"> | ||||
|     <span slot="headline">${item.primary}</span> | ||||
|   </ha-combo-box-item> | ||||
| `; | ||||
|  | ||||
| const HIDDEN_ATTRIBUTES = [ | ||||
|   "access_token", | ||||
| @@ -74,7 +90,7 @@ const HIDDEN_ATTRIBUTES = [ | ||||
| ]; | ||||
|  | ||||
| @customElement("ha-entity-state-content-picker") | ||||
| class HaEntityStatePicker extends LitElement { | ||||
| export class HaStateContentPicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: false }) public entityId?: string; | ||||
| @@ -95,26 +111,28 @@ class HaEntityStatePicker extends LitElement { | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|   @query(".container", true) private _container?: HTMLDivElement; | ||||
|  | ||||
|   @query("ha-combo-box", true) private _comboBox!: HaComboBox; | ||||
|  | ||||
|   protected shouldUpdate(changedProps: PropertyValues) { | ||||
|     return !(!changedProps.has("_opened") && this._opened); | ||||
|   } | ||||
|   @state() private _opened = false; | ||||
|  | ||||
|   private options = memoizeOne( | ||||
|   private _editIndex?: number; | ||||
|  | ||||
|   private _options = memoizeOne( | ||||
|     (entityId?: string, stateObj?: HassEntity, allowName?: boolean) => { | ||||
|       const domain = entityId ? computeDomain(entityId) : undefined; | ||||
|       return [ | ||||
|         { | ||||
|           label: this.hass.localize("ui.components.state-content-picker.state"), | ||||
|           primary: this.hass.localize( | ||||
|             "ui.components.state-content-picker.state" | ||||
|           ), | ||||
|           value: "state", | ||||
|         }, | ||||
|         ...(allowName | ||||
|           ? [ | ||||
|               { | ||||
|                 label: this.hass.localize( | ||||
|                 primary: this.hass.localize( | ||||
|                   "ui.components.state-content-picker.name" | ||||
|                 ), | ||||
|                 value: "name", | ||||
| @@ -122,13 +140,13 @@ class HaEntityStatePicker extends LitElement { | ||||
|             ] | ||||
|           : []), | ||||
|         { | ||||
|           label: this.hass.localize( | ||||
|           primary: this.hass.localize( | ||||
|             "ui.components.state-content-picker.last_changed" | ||||
|           ), | ||||
|           value: "last_changed", | ||||
|         }, | ||||
|         { | ||||
|           label: this.hass.localize( | ||||
|           primary: this.hass.localize( | ||||
|             "ui.components.state-content-picker.last_updated" | ||||
|           ), | ||||
|           value: "last_updated", | ||||
| @@ -137,7 +155,7 @@ class HaEntityStatePicker extends LitElement { | ||||
|           ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) => | ||||
|               STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content) | ||||
|             ).map((content) => ({ | ||||
|               label: this.hass.localize( | ||||
|               primary: this.hass.localize( | ||||
|                 `ui.components.state-content-picker.${content}` | ||||
|               ), | ||||
|               value: content, | ||||
| @@ -146,105 +164,201 @@ class HaEntityStatePicker extends LitElement { | ||||
|         ...Object.keys(stateObj?.attributes ?? {}) | ||||
|           .filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) | ||||
|           .map((attribute) => ({ | ||||
|             primary: this.hass.formatEntityAttributeName(stateObj!, attribute), | ||||
|             value: attribute, | ||||
|             label: this.hass.formatEntityAttributeName(stateObj!, attribute), | ||||
|           })), | ||||
|       ]; | ||||
|       ] satisfies StateContentOption[]; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _filter = ""; | ||||
|  | ||||
|   protected render() { | ||||
|     if (!this.hass) { | ||||
|       return nothing; | ||||
|     } | ||||
|  | ||||
|     const value = this._value; | ||||
|  | ||||
|     const stateObj = this.entityId | ||||
|       ? this.hass.states[this.entityId] | ||||
|       : undefined; | ||||
|  | ||||
|     const options = this.options(this.entityId, stateObj, this.allowName); | ||||
|     const optionItems = options.filter( | ||||
|       (option) => !this._value.includes(option.value) | ||||
|     ); | ||||
|     const options = this._options(this.entityId, stateObj, this.allowName); | ||||
|  | ||||
|     return html` | ||||
|       ${value?.length | ||||
|         ? html` | ||||
|             <ha-sortable | ||||
|               no-style | ||||
|               @item-moved=${this._moveItem} | ||||
|               .disabled=${this.disabled} | ||||
|               handle-selector="button.primary.action" | ||||
|             > | ||||
|               <ha-chip-set> | ||||
|                 ${repeat( | ||||
|                   this._value, | ||||
|                   (item) => item, | ||||
|                   (item, idx) => { | ||||
|                     const label = | ||||
|                       options.find((option) => option.value === item)?.label || | ||||
|                       item; | ||||
|                     return html` | ||||
|                       <ha-input-chip | ||||
|                         .idx=${idx} | ||||
|                         @remove=${this._removeItem} | ||||
|                         .label=${label} | ||||
|                         selected | ||||
|                       > | ||||
|                         <ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon> | ||||
|                         ${label} | ||||
|                       </ha-input-chip> | ||||
|                     `; | ||||
|                   } | ||||
|                 )} | ||||
|               </ha-chip-set> | ||||
|             </ha-sortable> | ||||
|           ` | ||||
|         : nothing} | ||||
|       ${this.label ? html`<label>${this.label}</label>` : nothing} | ||||
|       <div class="container ${this.disabled ? "disabled" : ""}"> | ||||
|         <ha-sortable | ||||
|           no-style | ||||
|           @item-moved=${this._moveItem} | ||||
|           .disabled=${this.disabled} | ||||
|           handle-selector="button.primary.action" | ||||
|           filter=".add" | ||||
|         > | ||||
|           <ha-chip-set> | ||||
|             ${repeat( | ||||
|               this._value, | ||||
|               (item) => item, | ||||
|               (item: string, idx) => { | ||||
|                 const label = options.find((o) => o.value === item)?.primary; | ||||
|                 const isValid = !!label; | ||||
|                 return html` | ||||
|                   <ha-input-chip | ||||
|                     data-idx=${idx} | ||||
|                     @remove=${this._removeItem} | ||||
|                     @click=${this._editItem} | ||||
|                     .label=${label || item} | ||||
|                     .selected=${!this.disabled} | ||||
|                     .disabled=${this.disabled} | ||||
|                     class=${!isValid ? "invalid" : ""} | ||||
|                   > | ||||
|                     <ha-svg-icon | ||||
|                       slot="icon" | ||||
|                       .path=${mdiDragHorizontalVariant} | ||||
|                     ></ha-svg-icon> | ||||
|                   </ha-input-chip> | ||||
|                 `; | ||||
|               } | ||||
|             )} | ||||
|             ${this.disabled | ||||
|               ? nothing | ||||
|               : html` | ||||
|                   <ha-assist-chip | ||||
|                     @click=${this._addItem} | ||||
|                     .disabled=${this.disabled} | ||||
|                     label=${this.hass.localize( | ||||
|                       "ui.components.entity.entity-state-content-picker.add" | ||||
|                     )} | ||||
|                     class="add" | ||||
|                   > | ||||
|                     <ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon> | ||||
|                   </ha-assist-chip> | ||||
|                 `} | ||||
|           </ha-chip-set> | ||||
|         </ha-sortable> | ||||
|  | ||||
|       <ha-combo-box | ||||
|         item-value-path="value" | ||||
|         item-label-path="label" | ||||
|         .hass=${this.hass} | ||||
|         .label=${this.label} | ||||
|         .helper=${this.helper} | ||||
|         .disabled=${this.disabled} | ||||
|         .required=${this.required && !value.length} | ||||
|         .value=${""} | ||||
|         .items=${optionItems} | ||||
|         allow-custom-value | ||||
|         @filter-changed=${this._filterChanged} | ||||
|         @value-changed=${this._comboBoxValueChanged} | ||||
|         @opened-changed=${this._openedChanged} | ||||
|       ></ha-combo-box> | ||||
|         <mwc-menu-surface | ||||
|           .open=${this._opened} | ||||
|           @closed=${this._onClosed} | ||||
|           @opened=${this._onOpened} | ||||
|           @input=${stopPropagation} | ||||
|           .anchor=${this._container} | ||||
|         > | ||||
|           <ha-combo-box | ||||
|             .hass=${this.hass} | ||||
|             .value=${""} | ||||
|             .autofocus=${this.autofocus} | ||||
|             .disabled=${this.disabled || !this.entityId} | ||||
|             .required=${this.required && !value.length} | ||||
|             .helper=${this.helper} | ||||
|             .items=${options} | ||||
|             allow-custom-value | ||||
|             item-id-path="value" | ||||
|             item-value-path="value" | ||||
|             item-label-path="primary" | ||||
|             .renderer=${rowRenderer} | ||||
|             @opened-changed=${this._openedChanged} | ||||
|             @value-changed=${this._comboBoxValueChanged} | ||||
|             @filter-changed=${this._filterChanged} | ||||
|           > | ||||
|           </ha-combo-box> | ||||
|         </mwc-menu-surface> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _onClosed(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     this._opened = false; | ||||
|     this._editIndex = undefined; | ||||
|   } | ||||
|  | ||||
|   private async _onOpened(ev) { | ||||
|     if (!this._opened) { | ||||
|       return; | ||||
|     } | ||||
|     ev.stopPropagation(); | ||||
|     this._opened = true; | ||||
|     await this._comboBox?.focus(); | ||||
|     await this._comboBox?.open(); | ||||
|   } | ||||
|  | ||||
|   private async _addItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   private async _editItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     const idx = parseInt(ev.currentTarget.dataset.idx, 10); | ||||
|     this._editIndex = idx; | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   private get _value() { | ||||
|     return !this.value ? [] : ensureArray(this.value); | ||||
|   } | ||||
|  | ||||
|   private _toValue = memoizeOne((value: string[]): typeof this.value => { | ||||
|     if (value.length === 0) { | ||||
|       return undefined; | ||||
|     } | ||||
|     if (value.length === 1) { | ||||
|       return value[0]; | ||||
|     } | ||||
|     return value; | ||||
|   }); | ||||
|  | ||||
|   private _openedChanged(ev: ValueChangedEvent<boolean>) { | ||||
|     this._opened = ev.detail.value; | ||||
|     this._comboBox.filteredItems = this._comboBox.items; | ||||
|     const open = ev.detail.value; | ||||
|     if (open) { | ||||
|       const options = this._comboBox.items || []; | ||||
|  | ||||
|       const initialValue = | ||||
|         this._editIndex != null ? this._value[this._editIndex] : ""; | ||||
|       const filteredItems = this._filterSelectedOptions(options, initialValue); | ||||
|  | ||||
|       this._comboBox.filteredItems = filteredItems; | ||||
|       this._comboBox.setInputValue(initialValue); | ||||
|     } else { | ||||
|       this._opened = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _filterChanged(ev?: CustomEvent): void { | ||||
|     this._filter = ev?.detail.value || ""; | ||||
|   private _filterSelectedOptions = ( | ||||
|     options: StateContentOption[], | ||||
|     current?: string | ||||
|   ) => { | ||||
|     const value = this._value; | ||||
|  | ||||
|     const filteredItems = this._comboBox.items?.filter((item) => { | ||||
|       const label = item.label || item.value; | ||||
|       return label.toLowerCase().includes(this._filter?.toLowerCase()); | ||||
|     }); | ||||
|     return options.filter( | ||||
|       (option) => !value.includes(option.value) || option.value === current | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|     if (this._filter) { | ||||
|       filteredItems?.unshift({ label: this._filter, value: this._filter }); | ||||
|   private _filterChanged(ev: ValueChangedEvent<string>) { | ||||
|     const input = ev.detail.value; | ||||
|     const filter = input?.toLowerCase() || ""; | ||||
|     const options = this._comboBox.items || []; | ||||
|  | ||||
|     const currentValue = | ||||
|       this._editIndex != null ? this._value[this._editIndex] : ""; | ||||
|  | ||||
|     this._comboBox.filteredItems = this._filterSelectedOptions( | ||||
|       options, | ||||
|       currentValue | ||||
|     ); | ||||
|  | ||||
|     if (!filter) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const fuseOptions: IFuseOptions<StateContentOption> = { | ||||
|       keys: ["primary", "secondary", "value"], | ||||
|       isCaseSensitive: false, | ||||
|       minMatchCharLength: Math.min(filter.length, 2), | ||||
|       threshold: 0.2, | ||||
|       ignoreDiacritics: true, | ||||
|     }; | ||||
|  | ||||
|     const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions); | ||||
|     const filteredItems = fuse.search(filter).map((result) => result.item); | ||||
|  | ||||
|     this._comboBox.filteredItems = filteredItems; | ||||
|   } | ||||
|  | ||||
| @@ -257,43 +371,40 @@ class HaEntityStatePicker extends LitElement { | ||||
|     newValue.splice(newIndex, 0, element); | ||||
|     this._setValue(newValue); | ||||
|     await this.updateComplete; | ||||
|     this._filterChanged(); | ||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); | ||||
|   } | ||||
|  | ||||
|   private async _removeItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     const value: string[] = [...this._value]; | ||||
|     value.splice(ev.target.idx, 1); | ||||
|     const value = [...this._value]; | ||||
|     const idx = parseInt(ev.target.dataset.idx, 10); | ||||
|     value.splice(idx, 1); | ||||
|     this._setValue(value); | ||||
|     await this.updateComplete; | ||||
|     this._filterChanged(); | ||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); | ||||
|   } | ||||
|  | ||||
|   private _comboBoxValueChanged(ev: CustomEvent): void { | ||||
|   private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void { | ||||
|     ev.stopPropagation(); | ||||
|     const newValue = ev.detail.value; | ||||
|     const value = ev.detail.value; | ||||
|  | ||||
|     if (this.disabled || newValue === "") { | ||||
|     if (this.disabled || value === "") { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const currentValue = this._value; | ||||
|     const newValue = [...this._value]; | ||||
|  | ||||
|     if (currentValue.includes(newValue)) { | ||||
|       return; | ||||
|     if (this._editIndex != null) { | ||||
|       newValue[this._editIndex] = value; | ||||
|     } else { | ||||
|       newValue.push(value); | ||||
|     } | ||||
|  | ||||
|     setTimeout(() => { | ||||
|       this._filterChanged(); | ||||
|       this._comboBox.setInputValue(""); | ||||
|     }, 0); | ||||
|  | ||||
|     this._setValue([...currentValue, newValue]); | ||||
|     this._setValue(newValue); | ||||
|   } | ||||
|  | ||||
|   private _setValue(value: string[]) { | ||||
|     const newValue = | ||||
|       value.length === 0 ? undefined : value.length === 1 ? value[0] : value; | ||||
|     const newValue = this._toValue(value); | ||||
|     this.value = newValue; | ||||
|     fireEvent(this, "value-changed", { | ||||
|       value: newValue, | ||||
| @@ -303,10 +414,64 @@ class HaEntityStatePicker extends LitElement { | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       position: relative; | ||||
|       width: 100%; | ||||
|     } | ||||
|  | ||||
|     .container { | ||||
|       position: relative; | ||||
|       background-color: var(--mdc-text-field-fill-color, whitesmoke); | ||||
|       border-radius: var(--ha-border-radius-sm); | ||||
|       border-end-end-radius: var(--ha-border-radius-square); | ||||
|       border-end-start-radius: var(--ha-border-radius-square); | ||||
|     } | ||||
|     .container:after { | ||||
|       display: block; | ||||
|       content: ""; | ||||
|       position: absolute; | ||||
|       pointer-events: none; | ||||
|       bottom: 0; | ||||
|       left: 0; | ||||
|       right: 0; | ||||
|       height: 1px; | ||||
|       width: 100%; | ||||
|       background-color: var( | ||||
|         --mdc-text-field-idle-line-color, | ||||
|         rgba(0, 0, 0, 0.42) | ||||
|       ); | ||||
|       transform: | ||||
|         height 180ms ease-in-out, | ||||
|         background-color 180ms ease-in-out; | ||||
|     } | ||||
|     .container.disabled:after { | ||||
|       background-color: var( | ||||
|         --mdc-text-field-disabled-line-color, | ||||
|         rgba(0, 0, 0, 0.42) | ||||
|       ); | ||||
|     } | ||||
|     .container:focus-within:after { | ||||
|       height: 2px; | ||||
|       background-color: var(--mdc-theme-primary); | ||||
|     } | ||||
|  | ||||
|     label { | ||||
|       display: block; | ||||
|       margin: 0 0 var(--ha-space-2); | ||||
|     } | ||||
|  | ||||
|     .add { | ||||
|       order: 1; | ||||
|     } | ||||
|  | ||||
|     mwc-menu-surface { | ||||
|       --mdc-menu-min-width: 100%; | ||||
|     } | ||||
|  | ||||
|     ha-chip-set { | ||||
|       padding: 8px 0; | ||||
|       padding: var(--ha-space-2) var(--ha-space-2); | ||||
|     } | ||||
|  | ||||
|     .invalid { | ||||
|       text-decoration: line-through; | ||||
|     } | ||||
|  | ||||
|     .sortable-fallback { | ||||
| @@ -326,6 +491,6 @@ class HaEntityStatePicker extends LitElement { | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-entity-state-content-picker": HaEntityStatePicker; | ||||
|     "ha-entity-state-content-picker": HaStateContentPicker; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { LitElement, html, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| @@ -9,8 +8,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; | ||||
| import "../ha-combo-box"; | ||||
| import type { HaComboBox } from "../ha-combo-box"; | ||||
|  | ||||
| export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; | ||||
|  | ||||
| interface StateOption { | ||||
|   value: string; | ||||
|   label: string; | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { customElement, property } from "lit/decorators"; | ||||
| import { keyed } from "lit/directives/keyed"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { ANY_STATE_VALUE } from "./const"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import "./ha-entity-state-picker"; | ||||
| @@ -57,6 +58,7 @@ export class HaEntityStatesPicker extends LitElement { | ||||
|  | ||||
|     const value = this.value || []; | ||||
|     const hide = [...(this.hideStates || []), ...value]; | ||||
|     const hideValue = value.includes(ANY_STATE_VALUE); | ||||
|  | ||||
|     return html` | ||||
|       ${repeat( | ||||
| @@ -84,7 +86,7 @@ export class HaEntityStatesPicker extends LitElement { | ||||
|         ` | ||||
|       )} | ||||
|       <div> | ||||
|         ${this.disabled && value.length | ||||
|         ${(this.disabled && value.length) || hideValue | ||||
|           ? nothing | ||||
|           : keyed( | ||||
|               value.length, | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { customElement, property, query } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; | ||||
| import { computeStateName } from "../../common/entity/compute_state_name"; | ||||
| import { computeRTL } from "../../common/util/compute_rtl"; | ||||
| import { domainToName } from "../../data/integration"; | ||||
| @@ -199,7 +200,7 @@ export class HaStatisticPicker extends LitElement { | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       const isRTL = computeRTL(this.hass); | ||||
|       const isRTL = computeRTL(hass); | ||||
|  | ||||
|       const output: StatisticComboBoxItem[] = []; | ||||
|  | ||||
| @@ -256,9 +257,15 @@ export class HaStatisticPicker extends LitElement { | ||||
|         const id = meta.statistic_id; | ||||
|  | ||||
|         const friendlyName = computeStateName(stateObj); // Keep this for search | ||||
|         const entityName = hass.formatEntityName(stateObj, "entity"); | ||||
|         const deviceName = hass.formatEntityName(stateObj, "device"); | ||||
|         const areaName = hass.formatEntityName(stateObj, "area"); | ||||
|  | ||||
|         const [entityName, deviceName, areaName] = computeEntityNameList( | ||||
|           stateObj, | ||||
|           [{ type: "entity" }, { type: "device" }, { type: "area" }], | ||||
|           hass.entities, | ||||
|           hass.devices, | ||||
|           hass.areas, | ||||
|           hass.floors | ||||
|         ); | ||||
|  | ||||
|         const primary = entityName || deviceName || id; | ||||
|         const secondary = [areaName, entityName ? deviceName : undefined] | ||||
| @@ -331,9 +338,14 @@ export class HaStatisticPicker extends LitElement { | ||||
|     const stateObj = this.hass.states[statisticId]; | ||||
|  | ||||
|     if (stateObj) { | ||||
|       const entityName = this.hass.formatEntityName(stateObj, "entity"); | ||||
|       const deviceName = this.hass.formatEntityName(stateObj, "device"); | ||||
|       const areaName = this.hass.formatEntityName(stateObj, "area"); | ||||
|       const [entityName, deviceName, areaName] = computeEntityNameList( | ||||
|         stateObj, | ||||
|         [{ type: "entity" }, { type: "device" }, { type: "area" }], | ||||
|         this.hass.entities, | ||||
|         this.hass.devices, | ||||
|         this.hass.areas, | ||||
|         this.hass.floors | ||||
|       ); | ||||
|  | ||||
|       const isRTL = computeRTL(this.hass); | ||||
|  | ||||
|   | ||||
| @@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement { | ||||
|         </span> | ||||
|         <ha-switch | ||||
|           @change=${this._handleRowClick} | ||||
|           .checked=${baseEnabled} | ||||
|           .checked=${!!baseEnabled} | ||||
|           .preference=${"base"} | ||||
|           .disabled=${loading} | ||||
|           name="base" | ||||
| @@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement { | ||||
|               <ha-switch | ||||
|                 .id="switch-${preference}" | ||||
|                 @change=${this._handleRowClick} | ||||
|                 .checked=${this.analytics?.preferences[preference]} | ||||
|                 .checked=${!!this.analytics?.preferences[preference]} | ||||
|                 .preference=${preference} | ||||
|                 name=${preference} | ||||
|               > | ||||
| @@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement { | ||||
|         </span> | ||||
|         <ha-switch | ||||
|           @change=${this._handleRowClick} | ||||
|           .checked=${this.analytics?.preferences.diagnostics} | ||||
|           .checked=${!!this.analytics?.preferences.diagnostics} | ||||
|           .preference=${"diagnostics"} | ||||
|           .disabled=${loading} | ||||
|           name="diagnostics" | ||||
|   | ||||
| @@ -8,21 +8,13 @@ import { styleMap } from "lit/directives/style-map"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { computeAreaName } from "../common/entity/compute_area_name"; | ||||
| import { computeDomain } from "../common/entity/compute_domain"; | ||||
| import { computeFloorName } from "../common/entity/compute_floor_name"; | ||||
| import { stringCompare } from "../common/string/compare"; | ||||
| import { computeRTL } from "../common/util/compute_rtl"; | ||||
| import type { AreaRegistryEntry } from "../data/area_registry"; | ||||
| import type { | ||||
|   DeviceEntityDisplayLookup, | ||||
|   DeviceRegistryEntry, | ||||
| } from "../data/device_registry"; | ||||
| import { getDeviceEntityDisplayLookup } from "../data/device_registry"; | ||||
| import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; | ||||
| import { | ||||
|   getFloorAreaLookup, | ||||
|   type FloorRegistryEntry, | ||||
| } from "../data/floor_registry"; | ||||
|   getAreasAndFloors, | ||||
|   type AreaFloorValue, | ||||
|   type FloorComboBoxItem, | ||||
| } from "../data/area_floor"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||
| import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; | ||||
| import "./ha-combo-box-item"; | ||||
| @@ -30,24 +22,12 @@ import "./ha-floor-icon"; | ||||
| import "./ha-generic-picker"; | ||||
| import type { HaGenericPicker } from "./ha-generic-picker"; | ||||
| import "./ha-icon-button"; | ||||
| import type { PickerComboBoxItem } from "./ha-picker-combo-box"; | ||||
| import type { PickerValueRenderer } from "./ha-picker-field"; | ||||
| import "./ha-svg-icon"; | ||||
| import "./ha-tree-indicator"; | ||||
|  | ||||
| const SEPARATOR = "________"; | ||||
|  | ||||
| interface FloorComboBoxItem extends PickerComboBoxItem { | ||||
|   type: "floor" | "area"; | ||||
|   floor?: FloorRegistryEntry; | ||||
|   area?: AreaRegistryEntry; | ||||
| } | ||||
|  | ||||
| interface AreaFloorValue { | ||||
|   id: string; | ||||
|   type: "floor" | "area"; | ||||
| } | ||||
|  | ||||
| @customElement("ha-area-floor-picker") | ||||
| export class HaAreaFloorPicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
| @@ -154,243 +134,6 @@ export class HaAreaFloorPicker extends LitElement { | ||||
|     `; | ||||
|   }; | ||||
|  | ||||
|   private _getAreasAndFloors = memoizeOne( | ||||
|     ( | ||||
|       haFloors: HomeAssistant["floors"], | ||||
|       haAreas: HomeAssistant["areas"], | ||||
|       haDevices: HomeAssistant["devices"], | ||||
|       haEntities: HomeAssistant["entities"], | ||||
|       includeDomains: this["includeDomains"], | ||||
|       excludeDomains: this["excludeDomains"], | ||||
|       includeDeviceClasses: this["includeDeviceClasses"], | ||||
|       deviceFilter: this["deviceFilter"], | ||||
|       entityFilter: this["entityFilter"], | ||||
|       excludeAreas: this["excludeAreas"], | ||||
|       excludeFloors: this["excludeFloors"] | ||||
|     ): FloorComboBoxItem[] => { | ||||
|       const floors = Object.values(haFloors); | ||||
|       const areas = Object.values(haAreas); | ||||
|       const devices = Object.values(haDevices); | ||||
|       const entities = Object.values(haEntities); | ||||
|  | ||||
|       let deviceEntityLookup: DeviceEntityDisplayLookup = {}; | ||||
|       let inputDevices: DeviceRegistryEntry[] | undefined; | ||||
|       let inputEntities: EntityRegistryDisplayEntry[] | undefined; | ||||
|  | ||||
|       if ( | ||||
|         includeDomains || | ||||
|         excludeDomains || | ||||
|         includeDeviceClasses || | ||||
|         deviceFilter || | ||||
|         entityFilter | ||||
|       ) { | ||||
|         deviceEntityLookup = getDeviceEntityDisplayLookup(entities); | ||||
|         inputDevices = devices; | ||||
|         inputEntities = entities.filter((entity) => entity.area_id); | ||||
|  | ||||
|         if (includeDomains) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return false; | ||||
|             } | ||||
|             return deviceEntityLookup[device.id].some((entity) => | ||||
|               includeDomains.includes(computeDomain(entity.entity_id)) | ||||
|             ); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter((entity) => | ||||
|             includeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         if (excludeDomains) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return true; | ||||
|             } | ||||
|             return entities.every( | ||||
|               (entity) => | ||||
|                 !excludeDomains.includes(computeDomain(entity.entity_id)) | ||||
|             ); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter( | ||||
|             (entity) => | ||||
|               !excludeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         if (includeDeviceClasses) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return false; | ||||
|             } | ||||
|             return deviceEntityLookup[device.id].some((entity) => { | ||||
|               const stateObj = this.hass.states[entity.entity_id]; | ||||
|               if (!stateObj) { | ||||
|                 return false; | ||||
|               } | ||||
|               return ( | ||||
|                 stateObj.attributes.device_class && | ||||
|                 includeDeviceClasses.includes(stateObj.attributes.device_class) | ||||
|               ); | ||||
|             }); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             return ( | ||||
|               stateObj.attributes.device_class && | ||||
|               includeDeviceClasses.includes(stateObj.attributes.device_class) | ||||
|             ); | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         if (deviceFilter) { | ||||
|           inputDevices = inputDevices!.filter((device) => | ||||
|             deviceFilter!(device) | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         if (entityFilter) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return false; | ||||
|             } | ||||
|             return deviceEntityLookup[device.id].some((entity) => { | ||||
|               const stateObj = this.hass.states[entity.entity_id]; | ||||
|               if (!stateObj) { | ||||
|                 return false; | ||||
|               } | ||||
|               return entityFilter(stateObj); | ||||
|             }); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             if (!stateObj) { | ||||
|               return false; | ||||
|             } | ||||
|             return entityFilter!(stateObj); | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       let outputAreas = areas; | ||||
|  | ||||
|       let areaIds: string[] | undefined; | ||||
|  | ||||
|       if (inputDevices) { | ||||
|         areaIds = inputDevices | ||||
|           .filter((device) => device.area_id) | ||||
|           .map((device) => device.area_id!); | ||||
|       } | ||||
|  | ||||
|       if (inputEntities) { | ||||
|         areaIds = (areaIds ?? []).concat( | ||||
|           inputEntities | ||||
|             .filter((entity) => entity.area_id) | ||||
|             .map((entity) => entity.area_id!) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (areaIds) { | ||||
|         outputAreas = outputAreas.filter((area) => | ||||
|           areaIds!.includes(area.area_id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (excludeAreas) { | ||||
|         outputAreas = outputAreas.filter( | ||||
|           (area) => !excludeAreas!.includes(area.area_id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (excludeFloors) { | ||||
|         outputAreas = outputAreas.filter( | ||||
|           (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       const floorAreaLookup = getFloorAreaLookup(outputAreas); | ||||
|       const unassisgnedAreas = Object.values(outputAreas).filter( | ||||
|         (area) => !area.floor_id || !floorAreaLookup[area.floor_id] | ||||
|       ); | ||||
|  | ||||
|       // @ts-ignore | ||||
|       const floorAreaEntries: [ | ||||
|         FloorRegistryEntry | undefined, | ||||
|         AreaRegistryEntry[], | ||||
|       ][] = Object.entries(floorAreaLookup) | ||||
|         .map(([floorId, floorAreas]) => { | ||||
|           const floor = floors.find((fl) => fl.floor_id === floorId)!; | ||||
|           return [floor, floorAreas] as const; | ||||
|         }) | ||||
|         .sort(([floorA], [floorB]) => { | ||||
|           if (floorA.level !== floorB.level) { | ||||
|             return (floorA.level ?? 0) - (floorB.level ?? 0); | ||||
|           } | ||||
|           return stringCompare(floorA.name, floorB.name); | ||||
|         }); | ||||
|  | ||||
|       const items: FloorComboBoxItem[] = []; | ||||
|  | ||||
|       floorAreaEntries.forEach(([floor, floorAreas]) => { | ||||
|         if (floor) { | ||||
|           const floorName = computeFloorName(floor); | ||||
|  | ||||
|           const areaSearchLabels = floorAreas | ||||
|             .map((area) => { | ||||
|               const areaName = computeAreaName(area) || area.area_id; | ||||
|               return [area.area_id, areaName, ...area.aliases]; | ||||
|             }) | ||||
|             .flat(); | ||||
|  | ||||
|           items.push({ | ||||
|             id: this._formatValue({ id: floor.floor_id, type: "floor" }), | ||||
|             type: "floor", | ||||
|             primary: floorName, | ||||
|             floor: floor, | ||||
|             search_labels: [ | ||||
|               floor.floor_id, | ||||
|               floorName, | ||||
|               ...floor.aliases, | ||||
|               ...areaSearchLabels, | ||||
|             ], | ||||
|           }); | ||||
|         } | ||||
|         items.push( | ||||
|           ...floorAreas.map((area) => { | ||||
|             const areaName = computeAreaName(area) || area.area_id; | ||||
|             return { | ||||
|               id: this._formatValue({ id: area.area_id, type: "area" }), | ||||
|               type: "area" as const, | ||||
|               primary: areaName, | ||||
|               area: area, | ||||
|               icon: area.icon || undefined, | ||||
|               search_labels: [area.area_id, areaName, ...area.aliases], | ||||
|             }; | ||||
|           }) | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       items.push( | ||||
|         ...unassisgnedAreas.map((area) => { | ||||
|           const areaName = computeAreaName(area) || area.area_id; | ||||
|           return { | ||||
|             id: this._formatValue({ id: area.area_id, type: "area" }), | ||||
|             type: "area" as const, | ||||
|             primary: areaName, | ||||
|             icon: area.icon || undefined, | ||||
|             search_labels: [area.area_id, areaName, ...area.aliases], | ||||
|           }; | ||||
|         }) | ||||
|       ); | ||||
|  | ||||
|       return items; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = ( | ||||
|     item, | ||||
|     { index }, | ||||
| @@ -445,12 +188,16 @@ export class HaAreaFloorPicker extends LitElement { | ||||
|     `; | ||||
|   }; | ||||
|  | ||||
|   private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); | ||||
|  | ||||
|   private _getItems = () => | ||||
|     this._getAreasAndFloors( | ||||
|     this._getAreasAndFloorsMemoized( | ||||
|       this.hass.states, | ||||
|       this.hass.floors, | ||||
|       this.hass.areas, | ||||
|       this.hass.devices, | ||||
|       this.hass.entities, | ||||
|       this._formatValue, | ||||
|       this.includeDomains, | ||||
|       this.excludeDomains, | ||||
|       this.includeDeviceClasses, | ||||
|   | ||||
| @@ -107,7 +107,7 @@ export class HaAreaPicker extends LitElement { | ||||
|           `; | ||||
|         } | ||||
|  | ||||
|         const { floor } = getAreaContext(area, this.hass); | ||||
|         const { floor } = getAreaContext(area, this.hass.floors); | ||||
|  | ||||
|         const areaName = area ? computeAreaName(area) : undefined; | ||||
|         const floorName = floor ? computeFloorName(floor) : undefined; | ||||
| @@ -279,7 +279,7 @@ export class HaAreaPicker extends LitElement { | ||||
|       } | ||||
|  | ||||
|       const items = outputAreas.map<PickerComboBoxItem>((area) => { | ||||
|         const { floor } = getAreaContext(area, this.hass); | ||||
|         const { floor } = getAreaContext(area, this.hass.floors); | ||||
|         const floorName = floor ? computeFloorName(floor) : undefined; | ||||
|         const areaName = computeAreaName(area); | ||||
|         return { | ||||
|   | ||||
| @@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement { | ||||
|     ); | ||||
|  | ||||
|     const items: DisplayItem[] = areas.map((area) => { | ||||
|       const { floor } = getAreaContext(area, this.hass!); | ||||
|       const { floor } = getAreaContext(area, this.hass.floors); | ||||
|       return { | ||||
|         value: area.area_id, | ||||
|         label: area.name, | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { mdiDrag, mdiTextureBox } from "@mdi/js"; | ||||
| import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js"; | ||||
| import type { TemplateResult } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
| @@ -105,7 +105,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement { | ||||
|                       <ha-svg-icon | ||||
|                         class="handle" | ||||
|                         slot="icons" | ||||
|                         .path=${mdiDrag} | ||||
|                         .path=${mdiDragHorizontalVariant} | ||||
|                       ></ha-svg-icon> | ||||
|                     `} | ||||
|                 <ha-items-display-editor | ||||
| @@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement { | ||||
|       ); | ||||
|       const groupedItems: Record<string, DisplayItem[]> = areas.reduce( | ||||
|         (acc, area) => { | ||||
|           const { floor } = getAreaContext(area, this.hass!); | ||||
|           const { floor } = getAreaContext(area, this.hass.floors); | ||||
|           const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR; | ||||
|  | ||||
|           if (!acc[floorId]) { | ||||
|   | ||||
| @@ -118,7 +118,7 @@ export class HaAutomationRow extends LitElement { | ||||
|     } | ||||
|     .row { | ||||
|       display: flex; | ||||
|       padding: 0 8px; | ||||
|       padding: var(--ha-space-0) var(--ha-space-2); | ||||
|       min-height: 48px; | ||||
|       align-items: center; | ||||
|       cursor: pointer; | ||||
| @@ -134,12 +134,12 @@ export class HaAutomationRow extends LitElement { | ||||
|     .expand-button { | ||||
|       transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       color: var(--ha-color-on-neutral-quiet); | ||||
|       margin-left: -8px; | ||||
|       margin-left: calc(var(--ha-space-2) * -1); | ||||
|     } | ||||
|     :host([building-block]) .leading-icon-wrapper { | ||||
|       background-color: var(--ha-color-fill-neutral-loud-resting); | ||||
|       border-radius: var(--ha-border-radius-md); | ||||
|       padding: 4px; | ||||
|       padding: var(--ha-space-1); | ||||
|       display: flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
| @@ -149,7 +149,7 @@ export class HaAutomationRow extends LitElement { | ||||
|       color: var(--ha-color-on-neutral-quiet); | ||||
|     } | ||||
|     :host([building-block]) ::slotted([slot="leading-icon"]) { | ||||
|       --mdc-icon-size: 20px; | ||||
|       --mdc-icon-size: var(--ha-space-5); | ||||
|       color: var(--white-color); | ||||
|       transform: rotate(-45deg); | ||||
|     } | ||||
| @@ -170,7 +170,7 @@ export class HaAutomationRow extends LitElement { | ||||
|     ::slotted([slot="header"]) { | ||||
|       flex: 1; | ||||
|       overflow-wrap: anywhere; | ||||
|       margin: 0 12px; | ||||
|       margin: var(--ha-space-0) var(--ha-space-3); | ||||
|     } | ||||
|     :host([sort-selected]) .row { | ||||
|       outline: solid; | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import { css, html, LitElement, type PropertyValues } from "lit"; | ||||
| import "@home-assistant/webawesome/dist/components/drawer/drawer"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { css, html, LitElement, type PropertyValues } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
|  | ||||
| export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | ||||
|  | ||||
| @@ -8,8 +10,17 @@ export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | ||||
| export class HaBottomSheet extends LitElement { | ||||
|   @property({ type: Boolean }) public open = false; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true, attribute: "flexcontent" }) | ||||
|   public flexContent = false; | ||||
|  | ||||
|   @state() private _drawerOpen = false; | ||||
|  | ||||
|   @query("#drawer") private _drawer!: HTMLElement; | ||||
|  | ||||
|   private _gestureRecognizer = new SwipeGestureRecognizer(); | ||||
|  | ||||
|   private _isDragging = false; | ||||
|  | ||||
|   private _handleAfterHide() { | ||||
|     this.open = false; | ||||
|     const ev = new Event("closed", { | ||||
| @@ -29,42 +40,186 @@ export class HaBottomSheet extends LitElement { | ||||
|   render() { | ||||
|     return html` | ||||
|       <wa-drawer | ||||
|         id="drawer" | ||||
|         placement="bottom" | ||||
|         .open=${this._drawerOpen} | ||||
|         @wa-after-hide=${this._handleAfterHide} | ||||
|         without-header | ||||
|         @touchstart=${this._handleTouchStart} | ||||
|       > | ||||
|         <slot></slot> | ||||
|         <slot name="header"></slot> | ||||
|         <div id="body" class="body ha-scrollbar"> | ||||
|           <slot></slot> | ||||
|         </div> | ||||
|       </wa-drawer> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     wa-drawer { | ||||
|       --wa-color-surface-raised: var( | ||||
|         --ha-bottom-sheet-surface-background, | ||||
|         var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), | ||||
|       ); | ||||
|       --spacing: 0; | ||||
|       --size: auto; | ||||
|       --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; | ||||
|       --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; | ||||
|   private _handleTouchStart = (ev: TouchEvent) => { | ||||
|     // Check if any element inside drawer in the composed path has scrollTop > 0 | ||||
|     for (const path of ev.composedPath()) { | ||||
|       const el = path as HTMLElement; | ||||
|       if (el === this._drawer) { | ||||
|         break; | ||||
|       } | ||||
|       if (el.scrollTop > 0) { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|     wa-drawer::part(dialog) { | ||||
|       border-top-left-radius: var( | ||||
|         --ha-bottom-sheet-border-radius, | ||||
|         var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||
|       ); | ||||
|       border-top-right-radius: var( | ||||
|         --ha-bottom-sheet-border-radius, | ||||
|         var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||
|       ); | ||||
|       max-height: 90vh; | ||||
|       padding-bottom: var(--safe-area-inset-bottom); | ||||
|       padding-left: var(--safe-area-inset-left); | ||||
|       padding-right: var(--safe-area-inset-right); | ||||
|  | ||||
|     this._startResizing(ev.touches[0].clientY); | ||||
|   }; | ||||
|  | ||||
|   private _startResizing(clientY: number) { | ||||
|     // register event listeners for drag handling | ||||
|     document.addEventListener("touchmove", this._handleTouchMove, { | ||||
|       passive: false, | ||||
|     }); | ||||
|     document.addEventListener("touchend", this._handleTouchEnd); | ||||
|     document.addEventListener("touchcancel", this._handleTouchEnd); | ||||
|  | ||||
|     this._gestureRecognizer.start(clientY); | ||||
|   } | ||||
|  | ||||
|   private _handleTouchMove = (ev: TouchEvent) => { | ||||
|     const currentY = ev.touches[0].clientY; | ||||
|     const delta = this._gestureRecognizer.move(currentY); | ||||
|  | ||||
|     if (delta < 0) { | ||||
|       ev.preventDefault(); | ||||
|       this._isDragging = true; | ||||
|       requestAnimationFrame(() => { | ||||
|         if (this._isDragging) { | ||||
|           this.style.setProperty( | ||||
|             "--dialog-transform", | ||||
|             `translateY(${delta * -1}px)` | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   `; | ||||
|   }; | ||||
|  | ||||
|   private _animateSnapBack() { | ||||
|     // Add transition for smooth animation | ||||
|     this.style.setProperty( | ||||
|       "--dialog-transition", | ||||
|       `transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease-out` | ||||
|     ); | ||||
|  | ||||
|     // Reset transform to snap back | ||||
|     this.style.removeProperty("--dialog-transform"); | ||||
|  | ||||
|     // Remove transition after animation completes | ||||
|     setTimeout(() => { | ||||
|       this.style.removeProperty("--dialog-transition"); | ||||
|     }, BOTTOM_SHEET_ANIMATION_DURATION_MS); | ||||
|   } | ||||
|  | ||||
|   private _handleTouchEnd = () => { | ||||
|     this._unregisterResizeHandlers(); | ||||
|  | ||||
|     this._isDragging = false; | ||||
|  | ||||
|     const result = this._gestureRecognizer.end(); | ||||
|  | ||||
|     // If velocity exceeds threshold, use velocity direction to determine action | ||||
|     if (result.isSwipe) { | ||||
|       if (result.isDownwardSwipe) { | ||||
|         // Downward swipe - close the bottom sheet | ||||
|         this._drawerOpen = false; | ||||
|       } else { | ||||
|         // Upward swipe - keep open and animate back | ||||
|         this._animateSnapBack(); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // If velocity is below threshold, use position-based logic | ||||
|     // Get the drawer height to calculate 50% threshold | ||||
|     const drawerBody = this._drawer.shadowRoot?.querySelector( | ||||
|       '[part="body"]' | ||||
|     ) as HTMLElement; | ||||
|     const drawerHeight = drawerBody?.offsetHeight || 0; | ||||
|  | ||||
|     // delta is negative when dragging down | ||||
|     // Close if dragged down past 50% of the drawer height | ||||
|     if ( | ||||
|       drawerHeight > 0 && | ||||
|       result.delta < 0 && | ||||
|       Math.abs(result.delta) > drawerHeight * 0.5 | ||||
|     ) { | ||||
|       this._drawerOpen = false; | ||||
|     } else { | ||||
|       this._animateSnapBack(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _unregisterResizeHandlers = () => { | ||||
|     document.removeEventListener("touchmove", this._handleTouchMove); | ||||
|     document.removeEventListener("touchend", this._handleTouchEnd); | ||||
|     document.removeEventListener("touchcancel", this._handleTouchEnd); | ||||
|   }; | ||||
|  | ||||
|   disconnectedCallback() { | ||||
|     super.disconnectedCallback(); | ||||
|     this._unregisterResizeHandlers(); | ||||
|     this._isDragging = false; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       wa-drawer { | ||||
|         --wa-color-surface-raised: transparent; | ||||
|         --spacing: 0; | ||||
|         --size: var(--ha-bottom-sheet-height, auto); | ||||
|         --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; | ||||
|         --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; | ||||
|       } | ||||
|       wa-drawer::part(dialog) { | ||||
|         max-height: var(--ha-bottom-sheet-max-height, 90vh); | ||||
|         align-items: center; | ||||
|         transform: var(--dialog-transform); | ||||
|         transition: var(--dialog-transition); | ||||
|       } | ||||
|       wa-drawer::part(body) { | ||||
|         max-width: var(--ha-bottom-sheet-max-width); | ||||
|         width: 100%; | ||||
|         border-top-left-radius: var( | ||||
|           --ha-bottom-sheet-border-radius, | ||||
|           var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||
|         ); | ||||
|         border-top-right-radius: var( | ||||
|           --ha-bottom-sheet-border-radius, | ||||
|           var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||
|         ); | ||||
|         background-color: var( | ||||
|           --ha-bottom-sheet-surface-background, | ||||
|           var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), | ||||
|         ); | ||||
|         padding: var( | ||||
|           --ha-bottom-sheet-padding, | ||||
|           0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) | ||||
|             var(--safe-area-inset-left) | ||||
|         ); | ||||
|       } | ||||
|       :host([flexcontent]) wa-drawer::part(body) { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
|       :host([flexcontent]) .body { | ||||
|         flex: 1; | ||||
|         max-width: 100%; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         padding: var( | ||||
|           --ha-bottom-sheet-padding, | ||||
|           0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) | ||||
|             var(--safe-area-inset-left) | ||||
|         ); | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -31,6 +31,9 @@ export class HaButtonToggleGroup extends LitElement { | ||||
|   @property({ type: Boolean, reflect: true, attribute: "no-wrap" }) | ||||
|   public nowrap = false; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true, attribute: "full-width" }) | ||||
|   public fullWidth = false; | ||||
|  | ||||
|   @property() public variant: | ||||
|     | "brand" | ||||
|     | "neutral" | ||||
| @@ -38,6 +41,13 @@ export class HaButtonToggleGroup extends LitElement { | ||||
|     | "warning" | ||||
|     | "danger" = "brand"; | ||||
|  | ||||
|   @property({ attribute: "active-variant" }) public activeVariant?: | ||||
|     | "brand" | ||||
|     | "neutral" | ||||
|     | "success" | ||||
|     | "warning" | ||||
|     | "danger"; | ||||
|  | ||||
|   protected render(): TemplateResult { | ||||
|     return html` | ||||
|       <wa-button-group childSelector="ha-button"> | ||||
| @@ -46,7 +56,9 @@ export class HaButtonToggleGroup extends LitElement { | ||||
|             html`<ha-button | ||||
|               iconTag="ha-svg-icon" | ||||
|               class="icon" | ||||
|               .variant=${this.variant} | ||||
|               .variant=${this.active !== button.value || !this.activeVariant | ||||
|                 ? this.variant | ||||
|                 : this.activeVariant} | ||||
|               .size=${this.size} | ||||
|               .value=${button.value} | ||||
|               @click=${this._handleClick} | ||||
| @@ -78,6 +90,19 @@ export class HaButtonToggleGroup extends LitElement { | ||||
|     :host([no-wrap]) wa-button-group::part(base) { | ||||
|       flex-wrap: nowrap; | ||||
|     } | ||||
|  | ||||
|     wa-button-group { | ||||
|       padding: var(--ha-button-toggle-group-padding); | ||||
|     } | ||||
|  | ||||
|     :host([full-width]) wa-button-group, | ||||
|     :host([full-width]) wa-button-group::part(base) { | ||||
|       width: 100%; | ||||
|     } | ||||
|  | ||||
|     :host([full-width]) ha-button { | ||||
|       flex: 1; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -86,7 +86,8 @@ export class HaCameraStream extends LitElement { | ||||
|     const streams = this._streams( | ||||
|       this._capabilities?.frontend_stream_types, | ||||
|       this._hlsStreams, | ||||
|       this._webRtcStreams | ||||
|       this._webRtcStreams, | ||||
|       this.muted | ||||
|     ); | ||||
|     return html`${repeat( | ||||
|       streams, | ||||
| @@ -190,7 +191,8 @@ export class HaCameraStream extends LitElement { | ||||
|     ( | ||||
|       supportedTypes?: StreamType[], | ||||
|       hlsStreams?: { hasAudio: boolean; hasVideo: boolean }, | ||||
|       webRtcStreams?: { hasAudio: boolean; hasVideo: boolean } | ||||
|       webRtcStreams?: { hasAudio: boolean; hasVideo: boolean }, | ||||
|       muted?: boolean | ||||
|     ): Stream[] => { | ||||
|       if (__DEMO__) { | ||||
|         return [{ type: MJPEG_STREAM, visible: true }]; | ||||
| @@ -220,9 +222,10 @@ export class HaCameraStream extends LitElement { | ||||
|         if ( | ||||
|           hlsStreams.hasVideo && | ||||
|           hlsStreams.hasAudio && | ||||
|           !webRtcStreams.hasAudio | ||||
|           !webRtcStreams.hasAudio && | ||||
|           !muted | ||||
|         ) { | ||||
|           // webRTC stream is missing audio, use HLS | ||||
|           // webRTC stream is missing audio and audio is not muted, use HLS | ||||
|           return [{ type: STREAM_TYPE_HLS, visible: true }]; | ||||
|         } | ||||
|         if (webRtcStreams.hasVideo) { | ||||
|   | ||||
| @@ -44,26 +44,26 @@ export class HaCard extends LitElement { | ||||
|       font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl)); | ||||
|       letter-spacing: -0.012em; | ||||
|       line-height: var(--ha-line-height-expanded); | ||||
|       padding: 12px 16px 16px; | ||||
|       padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4); | ||||
|       display: block; | ||||
|       margin-block-start: 0px; | ||||
|       margin-block-end: 0px; | ||||
|       margin-block-start: var(--ha-space-0); | ||||
|       margin-block-end: var(--ha-space-0); | ||||
|       font-weight: var(--ha-font-weight-normal); | ||||
|     } | ||||
|  | ||||
|     :host ::slotted(.card-content:not(:first-child)), | ||||
|     slot:not(:first-child)::slotted(.card-content) { | ||||
|       padding-top: 0px; | ||||
|       margin-top: -8px; | ||||
|       padding-top: var(--ha-space-0); | ||||
|       margin-top: calc(var(--ha-space-2) * -1); | ||||
|     } | ||||
|  | ||||
|     :host ::slotted(.card-content) { | ||||
|       padding: 16px; | ||||
|       padding: var(--ha-space-4); | ||||
|     } | ||||
|  | ||||
|     :host ::slotted(.card-actions) { | ||||
|       border-top: 1px solid var(--divider-color, #e8e8e8); | ||||
|       padding: 8px; | ||||
|       padding: var(--ha-space-2); | ||||
|     } | ||||
|   `; | ||||
|  | ||||
|   | ||||
| @@ -239,6 +239,7 @@ export class HaCodeEditor extends ReactiveElement { | ||||
|       this._loadedCodeMirror.crosshairCursor(), | ||||
|       this._loadedCodeMirror.highlightSelectionMatches(), | ||||
|       this._loadedCodeMirror.highlightActiveLine(), | ||||
|       this._loadedCodeMirror.dropCursor(), | ||||
|       this._loadedCodeMirror.indentationMarkers({ | ||||
|         thickness: 0, | ||||
|         activeThickness: 1, | ||||
|   | ||||
							
								
								
									
										52
									
								
								src/components/ha-dialog-footer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/components/ha-dialog-footer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement } from "lit/decorators"; | ||||
|  | ||||
| /** | ||||
|  * Home Assistant dialog footer component | ||||
|  * | ||||
|  * @element ha-dialog-footer | ||||
|  * @extends {LitElement} | ||||
|  * | ||||
|  * @summary | ||||
|  * A simple footer container for dialog actions, | ||||
|  * typically used as the `footer` slot in `ha-wa-dialog`. | ||||
|  * | ||||
|  * @slot primaryAction - Primary action button(s), aligned to the end. | ||||
|  * @slot secondaryAction - Secondary action button(s), placed before the primary action. | ||||
|  * | ||||
|  * @remarks | ||||
|  * **Button Styling Guidance:** | ||||
|  * - `primaryAction` slot: Use `variant="accent"` | ||||
|  * - `secondaryAction` slot: Use `variant="plain"` | ||||
|  */ | ||||
| @customElement("ha-dialog-footer") | ||||
| export class HaDialogFooter extends LitElement { | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <footer> | ||||
|         <slot name="secondaryAction"></slot> | ||||
|         <slot name="primaryAction"></slot> | ||||
|       </footer> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   static get styles() { | ||||
|     return [ | ||||
|       css` | ||||
|         footer { | ||||
|           display: flex; | ||||
|           gap: var(--ha-space-3); | ||||
|           justify-content: flex-end; | ||||
|           align-items: center; | ||||
|           width: 100%; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-dialog-footer": HaDialogFooter; | ||||
|   } | ||||
| } | ||||
| @@ -1,9 +1,23 @@ | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement } from "lit/decorators"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
|  | ||||
| @customElement("ha-dialog-header") | ||||
| export class HaDialogHeader extends LitElement { | ||||
|   @property({ type: String, attribute: "subtitle-position" }) | ||||
|   public subtitlePosition: "above" | "below" = "below"; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true, attribute: "show-border" }) | ||||
|   public showBorder = false; | ||||
|  | ||||
|   protected render() { | ||||
|     const titleSlot = html`<div class="header-title"> | ||||
|       <slot name="title"></slot> | ||||
|     </div>`; | ||||
|  | ||||
|     const subtitleSlot = html`<div class="header-subtitle"> | ||||
|       <slot name="subtitle"></slot> | ||||
|     </div>`; | ||||
|  | ||||
|     return html` | ||||
|       <header class="header"> | ||||
|         <div class="header-bar"> | ||||
| @@ -11,12 +25,9 @@ export class HaDialogHeader extends LitElement { | ||||
|             <slot name="navigationIcon"></slot> | ||||
|           </section> | ||||
|           <section class="header-content"> | ||||
|             <div class="header-title"> | ||||
|               <slot name="title"></slot> | ||||
|             </div> | ||||
|             <div class="header-subtitle"> | ||||
|               <slot name="subtitle"></slot> | ||||
|             </div> | ||||
|             ${this.subtitlePosition === "above" | ||||
|               ? html`${subtitleSlot}${titleSlot}` | ||||
|               : html`${titleSlot}${subtitleSlot}`} | ||||
|           </section> | ||||
|           <section class="header-action-items"> | ||||
|             <slot name="actionItems"></slot> | ||||
| @@ -40,43 +51,51 @@ export class HaDialogHeader extends LitElement { | ||||
|         .header-bar { | ||||
|           display: flex; | ||||
|           flex-direction: row; | ||||
|           align-items: flex-start; | ||||
|           padding: 4px; | ||||
|           align-items: center; | ||||
|           padding: 0 var(--ha-space-1); | ||||
|           box-sizing: border-box; | ||||
|         } | ||||
|         .header-content { | ||||
|           flex: 1; | ||||
|           padding: 10px 4px; | ||||
|           padding: 10px var(--ha-space-1); | ||||
|           display: flex; | ||||
|           flex-direction: column; | ||||
|           justify-content: center; | ||||
|           min-height: var(--ha-space-12); | ||||
|           min-width: 0; | ||||
|           overflow: hidden; | ||||
|           text-overflow: ellipsis; | ||||
|           white-space: nowrap; | ||||
|         } | ||||
|         .header-title { | ||||
|           height: var( | ||||
|             --ha-dialog-header-title-height, | ||||
|             calc(var(--ha-font-size-xl) + var(--ha-space-1)) | ||||
|           ); | ||||
|           font-size: var(--ha-font-size-xl); | ||||
|           line-height: var(--ha-line-height-condensed); | ||||
|           font-weight: var(--ha-font-weight-normal); | ||||
|           font-weight: var(--ha-font-weight-medium); | ||||
|         } | ||||
|         .header-subtitle { | ||||
|           font-size: var(--ha-font-size-m); | ||||
|           line-height: 20px; | ||||
|           line-height: var(--ha-line-height-normal); | ||||
|           color: var(--secondary-text-color); | ||||
|         } | ||||
|         @media all and (min-width: 450px) and (min-height: 500px) { | ||||
|           .header-bar { | ||||
|             padding: 16px; | ||||
|             padding: 0 var(--ha-space-2); | ||||
|           } | ||||
|         } | ||||
|         .header-navigation-icon { | ||||
|           flex: none; | ||||
|           min-width: 8px; | ||||
|           min-width: var(--ha-space-2); | ||||
|           height: 100%; | ||||
|           display: flex; | ||||
|           flex-direction: row; | ||||
|         } | ||||
|         .header-action-items { | ||||
|           flex: none; | ||||
|           min-width: 8px; | ||||
|           min-width: var(--ha-space-2); | ||||
|           height: 100%; | ||||
|           display: flex; | ||||
|           flex-direction: row; | ||||
|   | ||||
| @@ -121,7 +121,7 @@ export class HaDialog extends DialogBase { | ||||
|         position: var(--dialog-surface-position, relative); | ||||
|         top: var(--dialog-surface-top); | ||||
|         margin-top: var(--dialog-surface-margin-top); | ||||
|         min-width: var(--mdc-dialog-min-width, 100vw); | ||||
|         min-width: var(--mdc-dialog-min-width, auto); | ||||
|         min-height: var(--mdc-dialog-min-height, auto); | ||||
|         border-radius: var( | ||||
|           --ha-dialog-border-radius, | ||||
| @@ -133,25 +133,13 @@ export class HaDialog extends DialogBase { | ||||
|           --ha-dialog-surface-background, | ||||
|           var(--mdc-theme-surface, #fff) | ||||
|         ); | ||||
|         padding: var(--dialog-surface-padding); | ||||
|       } | ||||
|       :host([flexContent]) .mdc-dialog .mdc-dialog__content { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
|  | ||||
|       @media all and (max-width: 450px), all and (max-height: 500px) { | ||||
|         .mdc-dialog .mdc-dialog__surface { | ||||
|           min-height: 100vh; | ||||
|           min-height: 100svh; | ||||
|           max-height: 100vh; | ||||
|           max-height: 100svh; | ||||
|           padding-top: var(--safe-area-inset-top); | ||||
|           padding-bottom: var(--safe-area-inset-bottom); | ||||
|           padding-left: var(--safe-area-inset-left); | ||||
|           padding-right: var(--safe-area-inset-right); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .header_title { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|   | ||||
| @@ -49,6 +49,7 @@ export class HaExpansionPanel extends LitElement { | ||||
|           tabindex=${this.noCollapse ? -1 : 0} | ||||
|           aria-expanded=${this.expanded} | ||||
|           aria-controls="sect1" | ||||
|           part="summary" | ||||
|         > | ||||
|           ${this.leftChevron ? chevronIcon : nothing} | ||||
|           <slot name="leading-icon"></slot> | ||||
| @@ -170,6 +171,11 @@ export class HaExpansionPanel extends LitElement { | ||||
|       margin-left: 8px; | ||||
|       margin-inline-start: 8px; | ||||
|       margin-inline-end: initial; | ||||
|       border-radius: var(--ha-border-radius-circle); | ||||
|     } | ||||
|  | ||||
|     #summary:focus-visible ha-svg-icon.summary-icon { | ||||
|       background-color: var(--ha-color-fill-neutral-normal-active); | ||||
|     } | ||||
|  | ||||
|     :host([left-chevron]) .summary-icon, | ||||
|   | ||||
| @@ -248,7 +248,7 @@ export class HaFilterDevices extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: 0 8px; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: 0 8px; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: 0 8px; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: 0 8px; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: 0 8px; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -61,6 +61,7 @@ export class HaFormString extends LitElement implements HaFormElement { | ||||
|         .required=${this.schema.required} | ||||
|         .autoValidate=${this.schema.required} | ||||
|         .name=${this.schema.name} | ||||
|         .autofocus=${this.schema.autofocus} | ||||
|         .autocomplete=${this.schema.autocomplete} | ||||
|         .suffix=${this.isPassword | ||||
|           ? // reserve some space for the icon. | ||||
|   | ||||
| @@ -105,6 +105,11 @@ export class HaForm extends LitElement implements HaFormElement { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static shadowRootOptions: ShadowRootInit = { | ||||
|     mode: "open", | ||||
|     delegatesFocus: true, | ||||
|   }; | ||||
|  | ||||
|   protected render(): TemplateResult { | ||||
|     return html` | ||||
|       <div class="root" part="root"> | ||||
|   | ||||
| @@ -1,10 +1,14 @@ | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; | ||||
| import "@home-assistant/webawesome/dist/components/popover/popover"; | ||||
| import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; | ||||
| import { mdiPlaylistPlus } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
| import { tinykeys } from "tinykeys"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import "./ha-bottom-sheet"; | ||||
| import "./ha-button"; | ||||
| import "./ha-combo-box-item"; | ||||
| import "./ha-icon-button"; | ||||
| import "./ha-input-helper-text"; | ||||
| @@ -15,12 +19,12 @@ import type { | ||||
|   PickerComboBoxSearchFn, | ||||
| } from "./ha-picker-combo-box"; | ||||
| import "./ha-picker-field"; | ||||
| import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field"; | ||||
| import type { PickerValueRenderer } from "./ha-picker-field"; | ||||
| import "./ha-svg-icon"; | ||||
|  | ||||
| @customElement("ha-generic-picker") | ||||
| export class HaGenericPicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|   @property({ attribute: false }) public hass?: HomeAssistant; | ||||
|  | ||||
|   // eslint-disable-next-line lit/no-native-attributes | ||||
|   @property({ type: Boolean }) public autofocus = false; | ||||
| @@ -53,7 +57,7 @@ export class HaGenericPicker extends LitElement { | ||||
|   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>; | ||||
|   public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public valueRenderer?: PickerValueRenderer; | ||||
| @@ -64,58 +68,142 @@ export class HaGenericPicker extends LitElement { | ||||
|   @property({ attribute: "not-found-label", type: String }) | ||||
|   public notFoundLabel?: string; | ||||
|  | ||||
|   @query("ha-picker-field") private _field?: HaPickerField; | ||||
|   @property({ attribute: "popover-placement" }) | ||||
|   public popoverPlacement: | ||||
|     | "bottom" | ||||
|     | "top" | ||||
|     | "left" | ||||
|     | "right" | ||||
|     | "top-start" | ||||
|     | "top-end" | ||||
|     | "right-start" | ||||
|     | "right-end" | ||||
|     | "bottom-start" | ||||
|     | "bottom-end" | ||||
|     | "left-start" | ||||
|     | "left-end" = "bottom-start"; | ||||
|  | ||||
|   /** If set picker shows an add button instead of textbox when value isn't set */ | ||||
|   @property({ attribute: "add-button-label" }) public addButtonLabel?: string; | ||||
|  | ||||
|   @query(".container") private _containerElement?: HTMLDivElement; | ||||
|  | ||||
|   @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|  | ||||
|   @state() private _pickerWrapperOpen = false; | ||||
|  | ||||
|   @state() private _popoverWidth = 0; | ||||
|  | ||||
|   @state() private _openedNarrow = false; | ||||
|  | ||||
|   private _narrow = false; | ||||
|  | ||||
|   // helper to set new value after closing picker, to avoid flicker | ||||
|   private _newValue?: string; | ||||
|  | ||||
|   private _unsubscribeTinyKeys?: () => void; | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       ${this.label | ||||
|         ? html`<label ?disabled=${this.disabled}>${this.label}</label>` | ||||
|         : nothing} | ||||
|       <div class="container"> | ||||
|         ${!this._opened | ||||
|         <div id="picker"> | ||||
|           <slot name="field"> | ||||
|             ${this.addButtonLabel && !this.value | ||||
|               ? html`<ha-button | ||||
|                   size="small" | ||||
|                   appearance="filled" | ||||
|                   @click=${this.open} | ||||
|                   .disabled=${this.disabled} | ||||
|                 > | ||||
|                   <ha-svg-icon | ||||
|                     .path=${mdiPlaylistPlus} | ||||
|                     slot="start" | ||||
|                   ></ha-svg-icon> | ||||
|                   ${this.addButtonLabel} | ||||
|                 </ha-button>` | ||||
|               : html`<ha-picker-field | ||||
|                   type="button" | ||||
|                   class=${this._opened ? "opened" : ""} | ||||
|                   compact | ||||
|                   aria-label=${ifDefined(this.label)} | ||||
|                   @click=${this.open} | ||||
|                   @clear=${this._clear} | ||||
|                   .placeholder=${this.placeholder} | ||||
|                   .value=${this.value} | ||||
|                   .required=${this.required} | ||||
|                   .disabled=${this.disabled} | ||||
|                   .hideClearIcon=${this.hideClearIcon} | ||||
|                   .valueRenderer=${this.valueRenderer} | ||||
|                 > | ||||
|                 </ha-picker-field>`} | ||||
|           </slot> | ||||
|         </div> | ||||
|         ${!this._openedNarrow && (this._pickerWrapperOpen || this._opened) | ||||
|           ? html` | ||||
|               <ha-picker-field | ||||
|                 type="button" | ||||
|                 compact | ||||
|                 aria-label=${ifDefined(this.label)} | ||||
|                 @click=${this.open} | ||||
|                 @clear=${this._clear} | ||||
|                 .placeholder=${this.placeholder} | ||||
|                 .value=${this.value} | ||||
|                 .required=${this.required} | ||||
|                 .disabled=${this.disabled} | ||||
|                 .hideClearIcon=${this.hideClearIcon} | ||||
|                 .valueRenderer=${this.valueRenderer} | ||||
|               <wa-popover | ||||
|                 .open=${this._pickerWrapperOpen} | ||||
|                 style="--body-width: ${this._popoverWidth}px;" | ||||
|                 without-arrow | ||||
|                 distance="-4" | ||||
|                 .placement=${this.popoverPlacement} | ||||
|                 for="picker" | ||||
|                 auto-size="vertical" | ||||
|                 auto-size-padding="16" | ||||
|                 @wa-after-show=${this._dialogOpened} | ||||
|                 @wa-after-hide=${this._hidePicker} | ||||
|                 trap-focus | ||||
|                 role="dialog" | ||||
|                 aria-modal="true" | ||||
|                 aria-label=${this.label || "Select option"} | ||||
|               > | ||||
|               </ha-picker-field> | ||||
|                 ${this._renderComboBox()} | ||||
|               </wa-popover> | ||||
|             ` | ||||
|           : html` | ||||
|               <ha-picker-combo-box | ||||
|                 .hass=${this.hass} | ||||
|                 .autofocus=${this.autofocus} | ||||
|                 .allowCustomValue=${this.allowCustomValue} | ||||
|                 .label=${this.searchLabel ?? | ||||
|                 this.hass.localize("ui.common.search")} | ||||
|                 .value=${this.value} | ||||
|                 hide-clear-icon | ||||
|                 @opened-changed=${this._openedChanged} | ||||
|                 @value-changed=${this._valueChanged} | ||||
|                 .rowRenderer=${this.rowRenderer} | ||||
|                 .notFoundLabel=${this.notFoundLabel} | ||||
|                 .getItems=${this.getItems} | ||||
|                 .getAdditionalItems=${this.getAdditionalItems} | ||||
|                 .searchFn=${this.searchFn} | ||||
|               ></ha-picker-combo-box> | ||||
|             `} | ||||
|           : this._pickerWrapperOpen || this._opened | ||||
|             ? html`<ha-bottom-sheet | ||||
|                 flexcontent | ||||
|                 .open=${this._pickerWrapperOpen} | ||||
|                 @wa-after-show=${this._dialogOpened} | ||||
|                 @closed=${this._hidePicker} | ||||
|                 role="dialog" | ||||
|                 aria-modal="true" | ||||
|                 aria-label=${this.label || "Select option"} | ||||
|               > | ||||
|                 ${this._renderComboBox(true)} | ||||
|               </ha-bottom-sheet>` | ||||
|             : nothing} | ||||
|       </div> | ||||
|       ${this._renderHelper()} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderComboBox(dialogMode = false) { | ||||
|     if (!this._opened) { | ||||
|       return nothing; | ||||
|     } | ||||
|     return html` | ||||
|       <ha-picker-combo-box | ||||
|         .hass=${this.hass} | ||||
|         .allowCustomValue=${this.allowCustomValue} | ||||
|         .label=${this.searchLabel ?? | ||||
|         (this.hass?.localize("ui.common.search") || "Search")} | ||||
|         .value=${this.value} | ||||
|         @value-changed=${this._valueChanged} | ||||
|         .rowRenderer=${this.rowRenderer} | ||||
|         .notFoundLabel=${this.notFoundLabel} | ||||
|         .getItems=${this.getItems} | ||||
|         .getAdditionalItems=${this.getAdditionalItems} | ||||
|         .searchFn=${this.searchFn} | ||||
|         .mode=${dialogMode ? "dialog" : "popover"} | ||||
|       ></ha-picker-combo-box> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderHelper() { | ||||
|     return this.helper | ||||
|       ? html`<ha-input-helper-text .disabled=${this.disabled} | ||||
| @@ -124,13 +212,33 @@ export class HaGenericPicker extends LitElement { | ||||
|       : nothing; | ||||
|   } | ||||
|  | ||||
|   private _dialogOpened = () => { | ||||
|     this._opened = true; | ||||
|     requestAnimationFrame(() => { | ||||
|       this._comboBox?.focus(); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   private _hidePicker(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     if (this._newValue) { | ||||
|       fireEvent(this, "value-changed", { value: this._newValue }); | ||||
|       this._newValue = undefined; | ||||
|     } | ||||
|  | ||||
|     this._opened = false; | ||||
|     this._pickerWrapperOpen = false; | ||||
|     this._unsubscribeTinyKeys?.(); | ||||
|   } | ||||
|  | ||||
|   private _valueChanged(ev: CustomEvent) { | ||||
|     ev.stopPropagation(); | ||||
|     const value = ev.detail.value; | ||||
|     if (!value) { | ||||
|       return; | ||||
|     } | ||||
|     fireEvent(this, "value-changed", { value }); | ||||
|     this._pickerWrapperOpen = false; | ||||
|     this._newValue = value; | ||||
|   } | ||||
|  | ||||
|   private _clear(e) { | ||||
| @@ -143,25 +251,45 @@ export class HaGenericPicker extends LitElement { | ||||
|     fireEvent(this, "value-changed", { value }); | ||||
|   } | ||||
|  | ||||
|   public async open() { | ||||
|   public async open(ev?: Event) { | ||||
|     ev?.stopPropagation(); | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|     this._opened = true; | ||||
|     await this.updateComplete; | ||||
|     this._comboBox?.focus(); | ||||
|     this._comboBox?.open(); | ||||
|     this._openedNarrow = this._narrow; | ||||
|     this._popoverWidth = this._containerElement?.offsetWidth || 250; | ||||
|     this._pickerWrapperOpen = true; | ||||
|     this._unsubscribeTinyKeys = tinykeys(this, { | ||||
|       Escape: this._handleEscClose, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { | ||||
|     const opened = ev.detail.value; | ||||
|     if (this._opened && !opened) { | ||||
|       this._opened = false; | ||||
|       await this.updateComplete; | ||||
|       this._field?.focus(); | ||||
|     } | ||||
|   connectedCallback() { | ||||
|     super.connectedCallback(); | ||||
|     this._handleResize(); | ||||
|     window.addEventListener("resize", this._handleResize); | ||||
|   } | ||||
|  | ||||
|   public disconnectedCallback() { | ||||
|     super.disconnectedCallback(); | ||||
|     window.removeEventListener("resize", this._handleResize); | ||||
|     this._unsubscribeTinyKeys?.(); | ||||
|   } | ||||
|  | ||||
|   private _handleResize = () => { | ||||
|     this._narrow = | ||||
|       window.matchMedia("(max-width: 870px)").matches || | ||||
|       window.matchMedia("(max-height: 500px)").matches; | ||||
|  | ||||
|     if (!this._openedNarrow && this._pickerWrapperOpen) { | ||||
|       this._popoverWidth = this._containerElement?.offsetWidth || 250; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _handleEscClose = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|   }; | ||||
|  | ||||
|   static get styles(): CSSResultGroup { | ||||
|     return [ | ||||
|       css` | ||||
| @@ -178,7 +306,45 @@ export class HaGenericPicker extends LitElement { | ||||
|         } | ||||
|         ha-input-helper-text { | ||||
|           display: block; | ||||
|           margin: 8px 0 0; | ||||
|           margin: var(--ha-space-2) 0 0; | ||||
|         } | ||||
|  | ||||
|         wa-popover { | ||||
|           --wa-space-l: var(--ha-space-0); | ||||
|         } | ||||
|  | ||||
|         wa-popover::part(body) { | ||||
|           width: max(var(--body-width), 250px); | ||||
|           max-width: max(var(--body-width), 250px); | ||||
|           max-height: 500px; | ||||
|           height: 70vh; | ||||
|           overflow: hidden; | ||||
|         } | ||||
|  | ||||
|         @media (max-height: 1000px) { | ||||
|           wa-popover::part(body) { | ||||
|             max-height: 400px; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         @media (max-height: 1000px) { | ||||
|           wa-popover::part(body) { | ||||
|             max-height: 400px; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         ha-bottom-sheet { | ||||
|           --ha-bottom-sheet-height: 90vh; | ||||
|           --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); | ||||
|           --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height); | ||||
|           --ha-bottom-sheet-max-width: 600px; | ||||
|           --ha-bottom-sheet-padding: var(--ha-space-0); | ||||
|           --ha-bottom-sheet-surface-background: var(--card-background-color); | ||||
|           --ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl); | ||||
|         } | ||||
|  | ||||
|         ha-picker-field.opened { | ||||
|           --mdc-text-field-idle-line-color: var(--primary-color); | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { ResizeController } from "@lit-labs/observers/resize-controller"; | ||||
| import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||
| import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||
| import type { TemplateResult } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| @@ -178,7 +178,7 @@ export class HaItemDisplayEditor extends LitElement { | ||||
|                             ? this._dragHandleKeydown | ||||
|                             : undefined} | ||||
|                           class="handle" | ||||
|                           .path=${mdiDrag} | ||||
|                           .path=${mdiDragHorizontalVariant} | ||||
|                           slot="end" | ||||
|                         ></ha-svg-icon> | ||||
|                       ` | ||||
|   | ||||
| @@ -2,19 +2,19 @@ import { mdiLabel, mdiPlus } from "@mdi/js"; | ||||
| import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; | ||||
| import type { TemplateResult } from "lit"; | ||||
| import { LitElement, html } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { | ||||
|   customElement, | ||||
|   property, | ||||
|   query, | ||||
|   queryAssignedElements, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { computeDomain } from "../common/entity/compute_domain"; | ||||
| import type { | ||||
|   DeviceEntityDisplayLookup, | ||||
|   DeviceRegistryEntry, | ||||
| } from "../data/device_registry"; | ||||
| import { getDeviceEntityDisplayLookup } from "../data/device_registry"; | ||||
| import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; | ||||
| import type { LabelRegistryEntry } from "../data/label_registry"; | ||||
| import { | ||||
|   createLabelRegistryEntry, | ||||
|   getLabels, | ||||
|   subscribeLabelRegistry, | ||||
| } from "../data/label_registry"; | ||||
| import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; | ||||
| @@ -90,6 +90,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | ||||
|  | ||||
|   @state() private _labels?: LabelRegistryEntry[]; | ||||
|  | ||||
|   @queryAssignedElements({ flatten: true }) | ||||
|   private _slotNodes?: NodeListOf<HTMLElement>; | ||||
|  | ||||
|   @query("ha-generic-picker") private _picker?: HaGenericPicker; | ||||
|  | ||||
|   public async open() { | ||||
| @@ -137,201 +140,22 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | ||||
|       } | ||||
|   ); | ||||
|  | ||||
|   private _getLabels = memoizeOne( | ||||
|     ( | ||||
|       labels: LabelRegistryEntry[] | undefined, | ||||
|       haAreas: HomeAssistant["areas"], | ||||
|       haDevices: HomeAssistant["devices"], | ||||
|       haEntities: HomeAssistant["entities"], | ||||
|       includeDomains: this["includeDomains"], | ||||
|       excludeDomains: this["excludeDomains"], | ||||
|       includeDeviceClasses: this["includeDeviceClasses"], | ||||
|       deviceFilter: this["deviceFilter"], | ||||
|       entityFilter: this["entityFilter"], | ||||
|       excludeLabels: this["excludeLabels"] | ||||
|     ): PickerComboBoxItem[] => { | ||||
|       if (!labels || labels.length === 0) { | ||||
|         return [ | ||||
|           { | ||||
|             id: NO_LABELS, | ||||
|             primary: this.hass.localize("ui.components.label-picker.no_labels"), | ||||
|             icon_path: mdiLabel, | ||||
|           }, | ||||
|         ]; | ||||
|       } | ||||
|   private _getLabelsMemoized = memoizeOne(getLabels); | ||||
|  | ||||
|       const devices = Object.values(haDevices); | ||||
|       const entities = Object.values(haEntities); | ||||
|  | ||||
|       let deviceEntityLookup: DeviceEntityDisplayLookup = {}; | ||||
|       let inputDevices: DeviceRegistryEntry[] | undefined; | ||||
|       let inputEntities: EntityRegistryDisplayEntry[] | undefined; | ||||
|  | ||||
|       if ( | ||||
|         includeDomains || | ||||
|         excludeDomains || | ||||
|         includeDeviceClasses || | ||||
|         deviceFilter || | ||||
|         entityFilter | ||||
|       ) { | ||||
|         deviceEntityLookup = getDeviceEntityDisplayLookup(entities); | ||||
|         inputDevices = devices; | ||||
|         inputEntities = entities.filter((entity) => entity.labels.length > 0); | ||||
|  | ||||
|         if (includeDomains) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return false; | ||||
|             } | ||||
|             return deviceEntityLookup[device.id].some((entity) => | ||||
|               includeDomains.includes(computeDomain(entity.entity_id)) | ||||
|             ); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter((entity) => | ||||
|             includeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         if (excludeDomains) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return true; | ||||
|             } | ||||
|             return entities.every( | ||||
|               (entity) => | ||||
|                 !excludeDomains.includes(computeDomain(entity.entity_id)) | ||||
|             ); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter( | ||||
|             (entity) => | ||||
|               !excludeDomains.includes(computeDomain(entity.entity_id)) | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         if (includeDeviceClasses) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return false; | ||||
|             } | ||||
|             return deviceEntityLookup[device.id].some((entity) => { | ||||
|               const stateObj = this.hass.states[entity.entity_id]; | ||||
|               if (!stateObj) { | ||||
|                 return false; | ||||
|               } | ||||
|               return ( | ||||
|                 stateObj.attributes.device_class && | ||||
|                 includeDeviceClasses.includes(stateObj.attributes.device_class) | ||||
|               ); | ||||
|             }); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             return ( | ||||
|               stateObj.attributes.device_class && | ||||
|               includeDeviceClasses.includes(stateObj.attributes.device_class) | ||||
|             ); | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         if (deviceFilter) { | ||||
|           inputDevices = inputDevices!.filter((device) => | ||||
|             deviceFilter!(device) | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         if (entityFilter) { | ||||
|           inputDevices = inputDevices!.filter((device) => { | ||||
|             const devEntities = deviceEntityLookup[device.id]; | ||||
|             if (!devEntities || !devEntities.length) { | ||||
|               return false; | ||||
|             } | ||||
|             return deviceEntityLookup[device.id].some((entity) => { | ||||
|               const stateObj = this.hass.states[entity.entity_id]; | ||||
|               if (!stateObj) { | ||||
|                 return false; | ||||
|               } | ||||
|               return entityFilter(stateObj); | ||||
|             }); | ||||
|           }); | ||||
|           inputEntities = inputEntities!.filter((entity) => { | ||||
|             const stateObj = this.hass.states[entity.entity_id]; | ||||
|             if (!stateObj) { | ||||
|               return false; | ||||
|             } | ||||
|             return entityFilter!(stateObj); | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       let outputLabels = labels; | ||||
|       const usedLabels = new Set<string>(); | ||||
|  | ||||
|       let areaIds: string[] | undefined; | ||||
|  | ||||
|       if (inputDevices) { | ||||
|         areaIds = inputDevices | ||||
|           .filter((device) => device.area_id) | ||||
|           .map((device) => device.area_id!); | ||||
|  | ||||
|         inputDevices.forEach((device) => { | ||||
|           device.labels.forEach((label) => usedLabels.add(label)); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (inputEntities) { | ||||
|         areaIds = (areaIds ?? []).concat( | ||||
|           inputEntities | ||||
|             .filter((entity) => entity.area_id) | ||||
|             .map((entity) => entity.area_id!) | ||||
|         ); | ||||
|         inputEntities.forEach((entity) => { | ||||
|           entity.labels.forEach((label) => usedLabels.add(label)); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (areaIds) { | ||||
|         areaIds.forEach((areaId) => { | ||||
|           const area = haAreas[areaId]; | ||||
|           area.labels.forEach((label) => usedLabels.add(label)); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (excludeLabels) { | ||||
|         outputLabels = outputLabels.filter( | ||||
|           (label) => !excludeLabels!.includes(label.label_id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       if (inputDevices || inputEntities) { | ||||
|         outputLabels = outputLabels.filter((label) => | ||||
|           usedLabels.has(label.label_id) | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       const items = outputLabels.map<PickerComboBoxItem>((label) => ({ | ||||
|         id: label.label_id, | ||||
|         primary: label.name, | ||||
|         icon: label.icon || undefined, | ||||
|         icon_path: label.icon ? undefined : mdiLabel, | ||||
|         sorting_label: label.name, | ||||
|         search_labels: [label.name, label.label_id, label.description].filter( | ||||
|           (v): v is string => Boolean(v) | ||||
|         ), | ||||
|       })); | ||||
|  | ||||
|       return items; | ||||
|   private _getItems = () => { | ||||
|     if (!this._labels || this._labels.length === 0) { | ||||
|       return [ | ||||
|         { | ||||
|           id: NO_LABELS, | ||||
|           primary: this.hass.localize("ui.components.label-picker.no_labels"), | ||||
|           icon_path: mdiLabel, | ||||
|         }, | ||||
|       ]; | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   private _getItems = () => | ||||
|     this._getLabels( | ||||
|     return this._getLabelsMemoized( | ||||
|       this.hass, | ||||
|       this._labels, | ||||
|       this.hass.areas, | ||||
|       this.hass.devices, | ||||
|       this.hass.entities, | ||||
|       this.includeDomains, | ||||
|       this.excludeDomains, | ||||
|       this.includeDeviceClasses, | ||||
| @@ -339,6 +163,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | ||||
|       this.entityFilter, | ||||
|       this.excludeLabels | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { | ||||
|     if (!labels) { | ||||
| @@ -395,12 +220,14 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | ||||
|  | ||||
|     return html` | ||||
|       <ha-generic-picker | ||||
|         .disabled=${this.disabled} | ||||
|         .hass=${this.hass} | ||||
|         .autofocus=${this.autofocus} | ||||
|         .label=${this.label} | ||||
|         .notFoundLabel=${this.hass.localize( | ||||
|           "ui.components.label-picker.no_match" | ||||
|         )} | ||||
|         .addButtonLabel=${this.hass.localize("ui.components.label-picker.add")} | ||||
|         .placeholder=${placeholder} | ||||
|         .value=${this.value} | ||||
|         .getItems=${this._getItems} | ||||
| @@ -408,6 +235,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | ||||
|         .valueRenderer=${valueRenderer} | ||||
|         @value-changed=${this._valueChanged} | ||||
|       > | ||||
|         <slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot> | ||||
|       </ha-generic-picker> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { mdiPlaylistPlus } from "@mdi/js"; | ||||
| import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; | ||||
| import type { TemplateResult } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| @@ -123,36 +124,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { | ||||
|     ); | ||||
|     return html` | ||||
|       ${this.label ? html`<label>${this.label}</label>` : nothing} | ||||
|       ${labels?.length | ||||
|         ? html`<ha-chip-set> | ||||
|             ${repeat( | ||||
|               labels, | ||||
|               (label) => label?.label_id, | ||||
|               (label) => { | ||||
|                 const color = label?.color | ||||
|                   ? computeCssColor(label.color) | ||||
|                   : undefined; | ||||
|                 return html` | ||||
|                   <ha-input-chip | ||||
|                     .item=${label} | ||||
|                     @remove=${this._removeItem} | ||||
|                     @click=${this._openDetail} | ||||
|                     .label=${label?.name} | ||||
|                     selected | ||||
|                     style=${color ? `--color: ${color}` : ""} | ||||
|                   > | ||||
|                     ${label?.icon | ||||
|                       ? html`<ha-icon | ||||
|                           slot="icon" | ||||
|                           .icon=${label.icon} | ||||
|                         ></ha-icon>` | ||||
|                       : nothing} | ||||
|                   </ha-input-chip> | ||||
|                 `; | ||||
|               } | ||||
|             )} | ||||
|           </ha-chip-set>` | ||||
|         : nothing} | ||||
|       <ha-label-picker | ||||
|         .hass=${this.hass} | ||||
|         .helper=${this.helper} | ||||
| @@ -162,6 +133,47 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { | ||||
|         .excludeLabels=${this.value} | ||||
|         @value-changed=${this._labelChanged} | ||||
|       > | ||||
|         <ha-chip-set> | ||||
|           ${labels?.length | ||||
|             ? repeat( | ||||
|                 labels, | ||||
|                 (label) => label?.label_id, | ||||
|                 (label) => { | ||||
|                   const color = label?.color | ||||
|                     ? computeCssColor(label.color) | ||||
|                     : undefined; | ||||
|                   return html` | ||||
|                     <ha-input-chip | ||||
|                       .item=${label} | ||||
|                       @remove=${this._removeItem} | ||||
|                       @click=${this._openDetail} | ||||
|                       .disabled=${this.disabled} | ||||
|                       .label=${label?.name} | ||||
|                       selected | ||||
|                       style=${color ? `--color: ${color}` : ""} | ||||
|                     > | ||||
|                       ${label?.icon | ||||
|                         ? html`<ha-icon | ||||
|                             slot="icon" | ||||
|                             .icon=${label.icon} | ||||
|                           ></ha-icon>` | ||||
|                         : nothing} | ||||
|                     </ha-input-chip> | ||||
|                   `; | ||||
|                 } | ||||
|               ) | ||||
|             : nothing} | ||||
|           <ha-button | ||||
|             id="picker" | ||||
|             size="small" | ||||
|             appearance="filled" | ||||
|             @click=${this._openPicker} | ||||
|             .disabled=${this.disabled} | ||||
|           > | ||||
|             <ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon> | ||||
|             ${this.hass.localize("ui.components.label-picker.add")} | ||||
|           </ha-button> | ||||
|         </ha-chip-set> | ||||
|       </ha-label-picker> | ||||
|     `; | ||||
|   } | ||||
| @@ -203,9 +215,25 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { | ||||
|     }, 0); | ||||
|   } | ||||
|  | ||||
|   private _openPicker(ev: Event) { | ||||
|     ev.stopPropagation(); | ||||
|     this.labelPicker.open(); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     ha-chip-set { | ||||
|       margin-bottom: 8px; | ||||
|       background-color: var(--mdc-text-field-fill-color); | ||||
|       border-bottom: 1px solid var(--ha-color-border-neutral-normal); | ||||
|       border-top-right-radius: var(--ha-border-radius-sm); | ||||
|       border-top-left-radius: var(--ha-border-radius-sm); | ||||
|       padding: var(--ha-space-3); | ||||
|     } | ||||
|     .placeholder { | ||||
|       color: var(--mdc-text-field-label-ink-color); | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       height: var(--ha-space-8); | ||||
|     } | ||||
|     ha-input-chip { | ||||
|       --md-input-chip-selected-container-color: var(--color, var(--grey-color)); | ||||
|   | ||||
| @@ -1,56 +1,58 @@ | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { stopPropagation } from "../common/dom/stop_propagation"; | ||||
| import { formatLanguageCode } from "../common/language/format_language"; | ||||
| import { caseInsensitiveStringCompare } from "../common/string/compare"; | ||||
| import type { FrontendLocaleData } from "../data/translation"; | ||||
| import { translationMetadata } from "../resources/translations-metadata"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||
| import "./ha-generic-picker"; | ||||
| import "./ha-list-item"; | ||||
| import type { PickerComboBoxItem } from "./ha-picker-combo-box"; | ||||
| import "./ha-select"; | ||||
| import type { HaSelect } from "./ha-select"; | ||||
|  | ||||
| export const getLanguageOptions = ( | ||||
|   languages: string[], | ||||
|   nativeName: boolean, | ||||
|   noSort: boolean, | ||||
|   locale?: FrontendLocaleData | ||||
| ) => { | ||||
|   let options: { label: string; value: string }[] = []; | ||||
| ): PickerComboBoxItem[] => { | ||||
|   let options: PickerComboBoxItem[] = []; | ||||
|  | ||||
|   if (nativeName) { | ||||
|     const translations = translationMetadata.translations; | ||||
|     options = languages.map((lang) => { | ||||
|       let label = translations[lang]?.nativeName; | ||||
|       if (!label) { | ||||
|       let primary = translations[lang]?.nativeName; | ||||
|       if (!primary) { | ||||
|         try { | ||||
|           // this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user | ||||
|           label = new Intl.DisplayNames(lang, { | ||||
|           primary = new Intl.DisplayNames(lang, { | ||||
|             type: "language", | ||||
|             fallback: "code", | ||||
|           }).of(lang)!; | ||||
|         } catch (_err) { | ||||
|           label = lang; | ||||
|           primary = lang; | ||||
|         } | ||||
|       } | ||||
|       return { | ||||
|         value: lang, | ||||
|         label, | ||||
|         id: lang, | ||||
|         primary, | ||||
|         search_labels: [primary], | ||||
|       }; | ||||
|     }); | ||||
|   } else if (locale) { | ||||
|     options = languages.map((lang) => ({ | ||||
|       value: lang, | ||||
|       label: formatLanguageCode(lang, locale), | ||||
|       id: lang, | ||||
|       primary: formatLanguageCode(lang, locale), | ||||
|       search_labels: [formatLanguageCode(lang, locale)], | ||||
|     })); | ||||
|   } | ||||
|  | ||||
|   if (!noSort && locale) { | ||||
|     options.sort((a, b) => | ||||
|       caseInsensitiveStringCompare(a.label, b.label, locale.language) | ||||
|       caseInsensitiveStringCompare(a.primary, b.primary, locale.language) | ||||
|     ); | ||||
|   } | ||||
|   return options; | ||||
| @@ -80,115 +82,69 @@ export class HaLanguagePicker extends LitElement { | ||||
|  | ||||
|   @state() _defaultLanguages: string[] = []; | ||||
|  | ||||
|   @query("ha-select") private _select!: HaSelect; | ||||
|  | ||||
|   protected firstUpdated(changedProps: PropertyValues) { | ||||
|     super.firstUpdated(changedProps); | ||||
|     this._computeDefaultLanguageOptions(); | ||||
|   } | ||||
|  | ||||
|   protected updated(changedProperties: PropertyValues) { | ||||
|     super.updated(changedProperties); | ||||
|  | ||||
|     const localeChanged = | ||||
|       changedProperties.has("hass") && | ||||
|       this.hass && | ||||
|       changedProperties.get("hass") && | ||||
|       changedProperties.get("hass").locale.language !== | ||||
|         this.hass.locale.language; | ||||
|     if ( | ||||
|       changedProperties.has("languages") || | ||||
|       changedProperties.has("value") || | ||||
|       localeChanged | ||||
|     ) { | ||||
|       this._select.layoutOptions(); | ||||
|       if (!this.disabled && this._select.value !== this.value) { | ||||
|         fireEvent(this, "value-changed", { value: this._select.value }); | ||||
|       } | ||||
|       if (!this.value) { | ||||
|         return; | ||||
|       } | ||||
|       const languageOptions = this._getLanguagesOptions( | ||||
|         this.languages ?? this._defaultLanguages, | ||||
|         this.nativeName, | ||||
|         this.noSort, | ||||
|         this.hass?.locale | ||||
|       ); | ||||
|       const selectedItemIndex = languageOptions.findIndex( | ||||
|         (option) => option.value === this.value | ||||
|       ); | ||||
|       if (selectedItemIndex === -1) { | ||||
|         this.value = undefined; | ||||
|       } | ||||
|       if (localeChanged) { | ||||
|         this._select.select(selectedItemIndex); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _getLanguagesOptions = memoizeOne(getLanguageOptions); | ||||
|  | ||||
|   private _computeDefaultLanguageOptions() { | ||||
|     this._defaultLanguages = Object.keys(translationMetadata.translations); | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     const languageOptions = this._getLanguagesOptions( | ||||
|   private _getItems = () => | ||||
|     this._getLanguagesOptions( | ||||
|       this.languages ?? this._defaultLanguages, | ||||
|       this.nativeName, | ||||
|       this.noSort, | ||||
|       this.hass?.locale | ||||
|     ); | ||||
|  | ||||
|   private _valueRenderer = (value) => { | ||||
|     const language = this._getItems().find( | ||||
|       (lang) => lang.id === value | ||||
|     )?.primary; | ||||
|     return html`<span slot="headline">${language ?? value}</span> `; | ||||
|   }; | ||||
|  | ||||
|   protected render() { | ||||
|     const value = | ||||
|       this.value ?? | ||||
|       (this.required && !this.disabled | ||||
|         ? languageOptions[0]?.value | ||||
|         : this.value); | ||||
|       (this.required && !this.disabled ? this._getItems()[0].id : this.value); | ||||
|  | ||||
|     return html` | ||||
|       <ha-select | ||||
|         .label=${this.label ?? | ||||
|       <ha-generic-picker | ||||
|         .hass=${this.hass} | ||||
|         .autofocus=${this.autofocus} | ||||
|         popover-placement="bottom-end" | ||||
|         .notFoundLabel=${this.hass?.localize( | ||||
|           "ui.components.language-picker.no_match" | ||||
|         )} | ||||
|         .placeholder=${this.label ?? | ||||
|         (this.hass?.localize("ui.components.language-picker.language") || | ||||
|           "Language")} | ||||
|         .value=${value || ""} | ||||
|         .required=${this.required} | ||||
|         .value=${value} | ||||
|         .valueRenderer=${this._valueRenderer} | ||||
|         .disabled=${this.disabled} | ||||
|         @selected=${this._changed} | ||||
|         @closed=${stopPropagation} | ||||
|         fixedMenuPosition | ||||
|         naturalMenuWidth | ||||
|         .inlineArrow=${this.inlineArrow} | ||||
|       > | ||||
|         ${languageOptions.length === 0 | ||||
|           ? html`<ha-list-item value="" | ||||
|               >${this.hass?.localize( | ||||
|                 "ui.components.language-picker.no_languages" | ||||
|               ) || "No languages"}</ha-list-item | ||||
|             >` | ||||
|           : languageOptions.map( | ||||
|               (option) => html` | ||||
|                 <ha-list-item .value=${option.value} | ||||
|                   >${option.label}</ha-list-item | ||||
|                 > | ||||
|               ` | ||||
|             )} | ||||
|       </ha-select> | ||||
|         .getItems=${this._getItems} | ||||
|         @value-changed=${this._changed} | ||||
|         hide-clear-icon | ||||
|       ></ha-generic-picker> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     ha-select { | ||||
|     ha-generic-picker { | ||||
|       width: 100%; | ||||
|       min-width: 200px; | ||||
|       display: block; | ||||
|     } | ||||
|   `; | ||||
|  | ||||
|   private _changed(ev): void { | ||||
|     const target = ev.target as HaSelect; | ||||
|     if (this.disabled || target.value === "" || target.value === this.value) { | ||||
|       return; | ||||
|     } | ||||
|     this.value = target.value; | ||||
|   private _changed(ev: ValueChangedEvent<string>): void { | ||||
|     ev.stopPropagation(); | ||||
|     this.value = ev.detail.value; | ||||
|     fireEvent(this, "value-changed", { value: this.value }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -50,7 +50,7 @@ export class HaMarkdown extends LitElement { | ||||
|     } | ||||
|     ha-alert { | ||||
|       display: block; | ||||
|       margin: 4px 0; | ||||
|       margin: var(--ha-space-1) 0; | ||||
|     } | ||||
|     a { | ||||
|       color: var(--primary-color); | ||||
| @@ -75,7 +75,7 @@ export class HaMarkdown extends LitElement { | ||||
|       padding: 0; | ||||
|     } | ||||
|     pre { | ||||
|       padding: 16px; | ||||
|       padding: var(--ha-space-4); | ||||
|       overflow: auto; | ||||
|       line-height: var(--ha-line-height-condensed); | ||||
|       font-family: var(--ha-font-family-code); | ||||
| @@ -95,7 +95,7 @@ export class HaMarkdown extends LitElement { | ||||
|     hr { | ||||
|       border-color: var(--divider-color); | ||||
|       border-bottom: none; | ||||
|       margin: 16px 0; | ||||
|       margin: var(--ha-space-4) 0; | ||||
|     } | ||||
|   ` as CSSResultGroup; | ||||
| } | ||||
|   | ||||
| @@ -1,19 +1,28 @@ | ||||
| import type { LitVirtualizer } from "@lit-labs/virtualizer"; | ||||
| import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; | ||||
| import { mdiMagnify } from "@mdi/js"; | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import Fuse from "fuse.js"; | ||||
| import type { PropertyValues, TemplateResult } from "lit"; | ||||
| import { html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { | ||||
|   customElement, | ||||
|   eventOptions, | ||||
|   property, | ||||
|   query, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { tinykeys } from "tinykeys"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { caseInsensitiveStringCompare } from "../common/string/compare"; | ||||
| import type { LocalizeFunc } from "../common/translations/localize"; | ||||
| import { HaFuse } from "../resources/fuse"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||
| import "./ha-combo-box"; | ||||
| import type { HaComboBox } from "./ha-combo-box"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
| import { loadVirtualizer } from "../resources/virtualizer"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import "./ha-combo-box-item"; | ||||
| import "./ha-icon"; | ||||
| import "./ha-textfield"; | ||||
| import type { HaTextField } from "./ha-textfield"; | ||||
|  | ||||
| export interface PickerComboBoxItem { | ||||
|   id: string; | ||||
| @@ -33,10 +42,13 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem { | ||||
|  | ||||
| const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___"; | ||||
|  | ||||
| const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = ( | ||||
| const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( | ||||
|   item | ||||
| ) => html` | ||||
|   <ha-combo-box-item type="button" compact> | ||||
|   <ha-combo-box-item | ||||
|     .type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"} | ||||
|     compact | ||||
|   > | ||||
|     ${item.icon | ||||
|       ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` | ||||
|       : item.icon_path | ||||
| @@ -57,7 +69,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = ( | ||||
|  | ||||
| @customElement("ha-picker-combo-box") | ||||
| export class HaPickerComboBox extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|   @property({ attribute: false }) public hass?: HomeAssistant; | ||||
|  | ||||
|   // eslint-disable-next-line lit/no-native-attributes | ||||
|   @property({ type: Boolean }) public autofocus = false; | ||||
| @@ -73,7 +85,7 @@ export class HaPickerComboBox extends LitElement { | ||||
|  | ||||
|   @property() public value?: string; | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|   @state() private _listScrolled = false; | ||||
|  | ||||
|   @property({ attribute: false, type: Array }) | ||||
|   public getItems?: () => PickerComboBoxItem[]; | ||||
| @@ -82,10 +94,7 @@ export class HaPickerComboBox extends LitElement { | ||||
|   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>; | ||||
|  | ||||
|   @property({ attribute: "hide-clear-icon", type: Boolean }) | ||||
|   public hideClearIcon = false; | ||||
|   public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; | ||||
|  | ||||
|   @property({ attribute: "not-found-label", type: String }) | ||||
|   public notFoundLabel?: string; | ||||
| @@ -93,33 +102,77 @@ export class HaPickerComboBox extends LitElement { | ||||
|   @property({ attribute: false }) | ||||
|   public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|   @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; | ||||
|  | ||||
|   @query("ha-combo-box", true) public comboBox!: HaComboBox; | ||||
|   @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; | ||||
|  | ||||
|   public async open() { | ||||
|     await this.updateComplete; | ||||
|     await this.comboBox?.open(); | ||||
|   @query("ha-textfield") private _searchFieldElement?: HaTextField; | ||||
|  | ||||
|   @state() private _items: PickerComboBoxItemWithLabel[] = []; | ||||
|  | ||||
|   private _allItems: PickerComboBoxItemWithLabel[] = []; | ||||
|  | ||||
|   private _selectedItemIndex = -1; | ||||
|  | ||||
|   static shadowRootOptions = { | ||||
|     ...LitElement.shadowRootOptions, | ||||
|     delegatesFocus: true, | ||||
|   }; | ||||
|  | ||||
|   private _removeKeyboardShortcuts?: () => void; | ||||
|  | ||||
|   protected firstUpdated() { | ||||
|     this._registerKeyboardShortcuts(); | ||||
|   } | ||||
|  | ||||
|   public async focus() { | ||||
|     await this.updateComplete; | ||||
|     await this.comboBox?.focus(); | ||||
|   public willUpdate() { | ||||
|     if (!this.hasUpdated) { | ||||
|       loadVirtualizer(); | ||||
|       this._allItems = this._getItems(); | ||||
|       this._items = this._allItems; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _initialItems = false; | ||||
|   disconnectedCallback() { | ||||
|     super.disconnectedCallback(); | ||||
|     this._removeKeyboardShortcuts?.(); | ||||
|   } | ||||
|  | ||||
|   private _items: PickerComboBoxItemWithLabel[] = []; | ||||
|   protected render() { | ||||
|     return html`<ha-textfield | ||||
|         .label=${this.label ?? | ||||
|         this.hass?.localize("ui.common.search") ?? | ||||
|         "Search"} | ||||
|         @input=${this._filterChanged} | ||||
|       ></ha-textfield> | ||||
|       <lit-virtualizer | ||||
|         @scroll=${this._onScrollList} | ||||
|         tabindex="0" | ||||
|         scroller | ||||
|         .items=${this._items} | ||||
|         .renderItem=${this._renderItem} | ||||
|         style="min-height: 36px;" | ||||
|         class=${this._listScrolled ? "scrolled" : ""} | ||||
|         @focus=${this._focusList} | ||||
|       > | ||||
|       </lit-virtualizer> `; | ||||
|   } | ||||
|  | ||||
|   private _defaultNotFoundItem = memoizeOne( | ||||
|     ( | ||||
|       label: this["notFoundLabel"], | ||||
|       localize: LocalizeFunc | ||||
|       localize?: LocalizeFunc | ||||
|     ): PickerComboBoxItemWithLabel => ({ | ||||
|       id: NO_MATCHING_ITEMS_FOUND_ID, | ||||
|       primary: label || localize("ui.components.combo-box.no_match"), | ||||
|       primary: | ||||
|         label || | ||||
|         (localize && localize("ui.components.combo-box.no_match")) || | ||||
|         "No matching items found", | ||||
|       icon_path: mdiMagnify, | ||||
|       a11y_label: label || localize("ui.components.combo-box.no_match"), | ||||
|       a11y_label: | ||||
|         label || | ||||
|         (localize && localize("ui.components.combo-box.no_match")) || | ||||
|         "No matching items found", | ||||
|     }) | ||||
|   ); | ||||
|  | ||||
| @@ -144,13 +197,13 @@ export class HaPickerComboBox extends LitElement { | ||||
|         caseInsensitiveStringCompare( | ||||
|           entityA.sorting_label!, | ||||
|           entityB.sorting_label!, | ||||
|           this.hass.locale.language | ||||
|           this.hass?.locale.language ?? navigator.language | ||||
|         ) | ||||
|       ); | ||||
|  | ||||
|     if (!sortedItems.length) { | ||||
|       sortedItems.push( | ||||
|         this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) | ||||
|         this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) | ||||
|       ); | ||||
|     } | ||||
|  | ||||
| @@ -159,99 +212,73 @@ export class HaPickerComboBox extends LitElement { | ||||
|     return sortedItems; | ||||
|   }; | ||||
|  | ||||
|   protected shouldUpdate(changedProps: PropertyValues) { | ||||
|     if ( | ||||
|       changedProps.has("value") || | ||||
|       changedProps.has("label") || | ||||
|       changedProps.has("disabled") | ||||
|     ) { | ||||
|       return true; | ||||
|     } | ||||
|     return !(!changedProps.has("_opened") && this._opened); | ||||
|   } | ||||
|   private _renderItem = (item: PickerComboBoxItem, index: number) => { | ||||
|     const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER; | ||||
|     return html`<div | ||||
|       id=${`list-item-${index}`} | ||||
|       class="combo-box-row ${this._value === item.id ? "current-value" : ""}" | ||||
|       .value=${item.id} | ||||
|       .index=${index} | ||||
|       @click=${this._valueSelected} | ||||
|     > | ||||
|       ${item.id === NO_MATCHING_ITEMS_FOUND_ID | ||||
|         ? DEFAULT_ROW_RENDERER(item, index) | ||||
|         : renderer(item, index)} | ||||
|     </div>`; | ||||
|   }; | ||||
|  | ||||
|   public willUpdate(changedProps: PropertyValues) { | ||||
|     if (changedProps.has("_opened") && this._opened) { | ||||
|       this._items = this._getItems(); | ||||
|       if (this._initialItems) { | ||||
|         this.comboBox.filteredItems = this._items; | ||||
|       } | ||||
|       this._initialItems = true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render(): TemplateResult { | ||||
|     return html` | ||||
|       <ha-combo-box | ||||
|         item-id-path="id" | ||||
|         item-value-path="id" | ||||
|         item-label-path="a11y_label" | ||||
|         clear-initial-value | ||||
|         .hass=${this.hass} | ||||
|         .value=${this._value} | ||||
|         .label=${this.label} | ||||
|         .helper=${this.helper} | ||||
|         .allowCustomValue=${this.allowCustomValue} | ||||
|         .filteredItems=${this._items} | ||||
|         .renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER} | ||||
|         .required=${this.required} | ||||
|         .disabled=${this.disabled} | ||||
|         .hideClearIcon=${this.hideClearIcon} | ||||
|         @opened-changed=${this._openedChanged} | ||||
|         @value-changed=${this._valueChanged} | ||||
|         @filter-changed=${this._filterChanged} | ||||
|       > | ||||
|       </ha-combo-box> | ||||
|     `; | ||||
|   @eventOptions({ passive: true }) | ||||
|   private _onScrollList(ev) { | ||||
|     const top = ev.target.scrollTop ?? 0; | ||||
|     this._listScrolled = top > 0; | ||||
|   } | ||||
|  | ||||
|   private get _value() { | ||||
|     return this.value || ""; | ||||
|   } | ||||
|  | ||||
|   private _openedChanged(ev: ValueChangedEvent<boolean>) { | ||||
|   private _valueSelected = (ev: Event) => { | ||||
|     ev.stopPropagation(); | ||||
|     if (ev.detail.value !== this._opened) { | ||||
|       this._opened = ev.detail.value; | ||||
|       fireEvent(this, "opened-changed", { value: this._opened }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _valueChanged(ev: ValueChangedEvent<string | undefined>) { | ||||
|     ev.stopPropagation(); | ||||
|     // Clear the input field to prevent showing the old value next time | ||||
|     this.comboBox.setTextFieldValue(""); | ||||
|     const newValue = ev.detail.value?.trim(); | ||||
|     const value = (ev.currentTarget as any).value as string; | ||||
|     const newValue = value?.trim(); | ||||
|  | ||||
|     if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (newValue !== this._value) { | ||||
|       this._setValue(newValue); | ||||
|     } | ||||
|   } | ||||
|     fireEvent(this, "value-changed", { value: newValue }); | ||||
|   }; | ||||
|  | ||||
|   private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => | ||||
|     Fuse.createIndex(["search_labels"], states) | ||||
|   ); | ||||
|  | ||||
|   private _filterChanged(ev: CustomEvent): void { | ||||
|     if (!this._opened) return; | ||||
|   private _filterChanged = (ev: Event) => { | ||||
|     const textfield = ev.target as HaTextField; | ||||
|     const searchString = textfield.value.trim(); | ||||
|  | ||||
|     const target = ev.target as HaComboBox; | ||||
|     const searchString = ev.detail.value.trim() as string; | ||||
|     if (!searchString) { | ||||
|       this._items = this._allItems; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const index = this._fuseIndex(this._items); | ||||
|     const fuse = new HaFuse(this._items, { shouldSort: false }, index); | ||||
|     const index = this._fuseIndex(this._allItems); | ||||
|     const fuse = new HaFuse( | ||||
|       this._allItems, | ||||
|       { | ||||
|         shouldSort: false, | ||||
|         minMatchCharLength: Math.min(searchString.length, 2), | ||||
|       }, | ||||
|       index | ||||
|     ); | ||||
|  | ||||
|     const results = fuse.multiTermsSearch(searchString); | ||||
|     let filteredItems = this._items as PickerComboBoxItem[]; | ||||
|     let filteredItems = this._allItems as PickerComboBoxItem[]; | ||||
|     if (results) { | ||||
|       const items = results.map((result) => result.item); | ||||
|       if (items.length === 0) { | ||||
|         items.push( | ||||
|           this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) | ||||
|           this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) | ||||
|         ); | ||||
|       } | ||||
|       const additionalItems = this._getAdditionalItems(searchString); | ||||
| @@ -260,17 +287,279 @@ export class HaPickerComboBox extends LitElement { | ||||
|     } | ||||
|  | ||||
|     if (this.searchFn) { | ||||
|       filteredItems = this.searchFn(searchString, filteredItems, this._items); | ||||
|       filteredItems = this.searchFn( | ||||
|         searchString, | ||||
|         filteredItems, | ||||
|         this._allItems | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     target.filteredItems = filteredItems; | ||||
|     this._items = filteredItems as PickerComboBoxItemWithLabel[]; | ||||
|     this._selectedItemIndex = -1; | ||||
|     if (this._virtualizerElement) { | ||||
|       this._virtualizerElement.scrollTo(0, 0); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _registerKeyboardShortcuts() { | ||||
|     this._removeKeyboardShortcuts = tinykeys(this, { | ||||
|       ArrowUp: this._selectPreviousItem, | ||||
|       ArrowDown: this._selectNextItem, | ||||
|       Home: this._selectFirstItem, | ||||
|       End: this._selectLastItem, | ||||
|       Enter: this._pickSelectedItem, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _setValue(value: string | undefined) { | ||||
|     setTimeout(() => { | ||||
|       fireEvent(this, "value-changed", { value }); | ||||
|     }, 0); | ||||
|   private _focusList() { | ||||
|     if (this._selectedItemIndex === -1) { | ||||
|       this._selectNextItem(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _selectNextItem = (ev?: KeyboardEvent) => { | ||||
|     ev?.stopPropagation(); | ||||
|     ev?.preventDefault(); | ||||
|     if (!this._virtualizerElement) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this._searchFieldElement?.focus(); | ||||
|  | ||||
|     const items = this._virtualizerElement.items as PickerComboBoxItem[]; | ||||
|  | ||||
|     const maxItems = items.length - 1; | ||||
|  | ||||
|     if (maxItems === -1) { | ||||
|       this._resetSelectedItem(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const nextIndex = | ||||
|       maxItems === this._selectedItemIndex | ||||
|         ? this._selectedItemIndex | ||||
|         : this._selectedItemIndex + 1; | ||||
|  | ||||
|     if (!items[nextIndex]) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       // Skip titles, padding and empty search | ||||
|       if (nextIndex === maxItems) { | ||||
|         return; | ||||
|       } | ||||
|       this._selectedItemIndex = nextIndex + 1; | ||||
|     } else { | ||||
|       this._selectedItemIndex = nextIndex; | ||||
|     } | ||||
|  | ||||
|     this._scrollToSelectedItem(); | ||||
|   }; | ||||
|  | ||||
|   private _selectPreviousItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     ev.preventDefault(); | ||||
|     if (!this._virtualizerElement) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (this._selectedItemIndex > 0) { | ||||
|       const nextIndex = this._selectedItemIndex - 1; | ||||
|  | ||||
|       const items = this._virtualizerElement.items as PickerComboBoxItem[]; | ||||
|  | ||||
|       if (!items[nextIndex]) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|         // Skip titles, padding and empty search | ||||
|         if (nextIndex === 0) { | ||||
|           return; | ||||
|         } | ||||
|         this._selectedItemIndex = nextIndex - 1; | ||||
|       } else { | ||||
|         this._selectedItemIndex = nextIndex; | ||||
|       } | ||||
|  | ||||
|       this._scrollToSelectedItem(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _selectFirstItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     if (!this._virtualizerElement || !this._virtualizerElement.items.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const nextIndex = 0; | ||||
|  | ||||
|     if ( | ||||
|       (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === | ||||
|       NO_MATCHING_ITEMS_FOUND_ID | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (typeof this._virtualizerElement.items[nextIndex] === "string") { | ||||
|       this._selectedItemIndex = nextIndex + 1; | ||||
|     } else { | ||||
|       this._selectedItemIndex = nextIndex; | ||||
|     } | ||||
|  | ||||
|     this._scrollToSelectedItem(); | ||||
|   }; | ||||
|  | ||||
|   private _selectLastItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     if (!this._virtualizerElement || !this._virtualizerElement.items.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const nextIndex = this._virtualizerElement.items.length - 1; | ||||
|  | ||||
|     if ( | ||||
|       (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === | ||||
|       NO_MATCHING_ITEMS_FOUND_ID | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (typeof this._virtualizerElement.items[nextIndex] === "string") { | ||||
|       this._selectedItemIndex = nextIndex - 1; | ||||
|     } else { | ||||
|       this._selectedItemIndex = nextIndex; | ||||
|     } | ||||
|  | ||||
|     this._scrollToSelectedItem(); | ||||
|   }; | ||||
|  | ||||
|   private _scrollToSelectedItem = () => { | ||||
|     this._virtualizerElement | ||||
|       ?.querySelector(".selected") | ||||
|       ?.classList.remove("selected"); | ||||
|  | ||||
|     this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end"); | ||||
|  | ||||
|     requestAnimationFrame(() => { | ||||
|       this._virtualizerElement | ||||
|         ?.querySelector(`#list-item-${this._selectedItemIndex}`) | ||||
|         ?.classList.add("selected"); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   private _pickSelectedItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem; | ||||
|  | ||||
|     if ( | ||||
|       this._virtualizerElement?.items.length === 1 && | ||||
|       firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID | ||||
|     ) { | ||||
|       fireEvent(this, "value-changed", { | ||||
|         value: firstItem.id, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (this._selectedItemIndex === -1) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // if filter button is focused | ||||
|     ev.preventDefault(); | ||||
|  | ||||
|     const item = this._virtualizerElement?.items[ | ||||
|       this._selectedItemIndex | ||||
|     ] as PickerComboBoxItem; | ||||
|     if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       fireEvent(this, "value-changed", { value: item.id }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _resetSelectedItem() { | ||||
|     this._virtualizerElement | ||||
|       ?.querySelector(".selected") | ||||
|       ?.classList.remove("selected"); | ||||
|     this._selectedItemIndex = -1; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       :host { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         padding-top: var(--ha-space-3); | ||||
|         flex: 1; | ||||
|       } | ||||
|  | ||||
|       ha-textfield { | ||||
|         padding: 0 var(--ha-space-3); | ||||
|         margin-bottom: var(--ha-space-3); | ||||
|       } | ||||
|  | ||||
|       :host([mode="dialog"]) ha-textfield { | ||||
|         padding: 0 var(--ha-space-4); | ||||
|       } | ||||
|  | ||||
|       ha-combo-box-item { | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       ha-combo-box-item.selected { | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         ha-combo-box-item.selected { | ||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer { | ||||
|         flex: 1; | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer:focus-visible { | ||||
|         outline: none; | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer.scrolled { | ||||
|         border-top: 1px solid var(--ha-color-border-neutral-quiet); | ||||
|       } | ||||
|  | ||||
|       .bottom-padding { | ||||
|         height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       .empty { | ||||
|         text-align: center; | ||||
|       } | ||||
|  | ||||
|       .combo-box-row { | ||||
|         display: flex; | ||||
|         width: 100%; | ||||
|         align-items: center; | ||||
|         box-sizing: border-box; | ||||
|         min-height: 36px; | ||||
|       } | ||||
|       .combo-box-row.current-value { | ||||
|         background-color: var(--ha-color-fill-primary-quiet-resting); | ||||
|       } | ||||
|  | ||||
|       .combo-box-row.selected { | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         .combo-box-row.selected { | ||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -39,6 +39,15 @@ export class HaPictureUpload extends LitElement { | ||||
|   @property({ type: Boolean, attribute: "select-media" }) public selectMedia = | ||||
|     false; | ||||
|  | ||||
|   // This property is set when this component is used inside a media selector. | ||||
|   // When set, it returns selected media or uploaded files as MediaSelectorValue | ||||
|   // When unset, it only allows selecting images from image-upload, and returns | ||||
|   // selected or uploaded images as a string starting with /api/... | ||||
|   @property({ type: Boolean, attribute: "full-media" }) public fullMedia = | ||||
|     false; | ||||
|  | ||||
|   @property({ attribute: false }) public contentIdHelper?: string; | ||||
|  | ||||
|   @property({ attribute: false }) public cropOptions?: CropOptions; | ||||
|  | ||||
|   @property({ type: Boolean }) public original = false; | ||||
| @@ -164,12 +173,33 @@ export class HaPictureUpload extends LitElement { | ||||
|     this._uploading = true; | ||||
|     try { | ||||
|       const media = await createImage(this.hass, file); | ||||
|       this.value = generateImageThumbnailUrl( | ||||
|         media.id, | ||||
|         this.size, | ||||
|         this.original | ||||
|       ); | ||||
|       fireEvent(this, "change"); | ||||
|       if (this.fullMedia) { | ||||
|         const item = { | ||||
|           media_content_id: `${MEDIA_PREFIX}/${media.id}`, | ||||
|           media_content_type: media.content_type, | ||||
|           title: media.name, | ||||
|           media_class: "image" as const, | ||||
|           can_play: true, | ||||
|           can_expand: false, | ||||
|           can_search: false, | ||||
|           thumbnail: generateImageThumbnailUrl(media.id, 256), | ||||
|         } as const; | ||||
|         const navigateIds = [ | ||||
|           {}, | ||||
|           { media_content_type: "app", media_content_id: MEDIA_PREFIX }, | ||||
|         ]; | ||||
|         fireEvent(this, "media-picked", { | ||||
|           item, | ||||
|           navigateIds, | ||||
|         }); | ||||
|       } else { | ||||
|         this.value = generateImageThumbnailUrl( | ||||
|           media.id, | ||||
|           this.size, | ||||
|           this.original | ||||
|         ); | ||||
|         fireEvent(this, "change"); | ||||
|       } | ||||
|     } catch (err: any) { | ||||
|       showAlertDialog(this, { | ||||
|         text: err.toString(), | ||||
| @@ -183,15 +213,24 @@ export class HaPictureUpload extends LitElement { | ||||
|     showMediaBrowserDialog(this, { | ||||
|       action: "pick", | ||||
|       entityId: "browser", | ||||
|       navigateIds: [ | ||||
|         { media_content_id: undefined, media_content_type: undefined }, | ||||
|         { | ||||
|           media_content_id: MEDIA_PREFIX, | ||||
|           media_content_type: "app", | ||||
|         }, | ||||
|       ], | ||||
|       minimumNavigateLevel: 2, | ||||
|       accept: ["image/*"], | ||||
|       navigateIds: this.fullMedia | ||||
|         ? undefined | ||||
|         : [ | ||||
|             { media_content_id: undefined, media_content_type: undefined }, | ||||
|             { | ||||
|               media_content_id: MEDIA_PREFIX, | ||||
|               media_content_type: "app", | ||||
|             }, | ||||
|           ], | ||||
|       minimumNavigateLevel: this.fullMedia ? undefined : 2, | ||||
|       hideContentType: true, | ||||
|       contentIdHelper: this.contentIdHelper, | ||||
|       mediaPickedCallback: async (pickedMedia: MediaPickedEvent) => { | ||||
|         if (this.fullMedia) { | ||||
|           fireEvent(this, "media-picked", pickedMedia); | ||||
|           return; | ||||
|         } | ||||
|         const mediaId = getIdFromUrl(pickedMedia.item.media_content_id); | ||||
|         if (mediaId) { | ||||
|           if (this.crop) { | ||||
|   | ||||
| @@ -220,7 +220,7 @@ export class HaResizableBottomSheet extends LitElement { | ||||
|       min-height: var(--min-height, 30dvh); | ||||
|       background-color: var( | ||||
|         --ha-bottom-sheet-surface-background, | ||||
|         var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), | ||||
|         var(--ha-color-surface-default) | ||||
|       ); | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|   | ||||
| @@ -137,7 +137,7 @@ export class HaSelect extends SelectBase { | ||||
|         height: var(--ha-select-height, 56px); | ||||
|       } | ||||
|       .mdc-select--filled .mdc-floating-label { | ||||
|         inset-inline-start: 12px; | ||||
|         inset-inline-start: var(--ha-space-4); | ||||
|         inset-inline-end: initial; | ||||
|         direction: var(--direction); | ||||
|       } | ||||
| @@ -147,7 +147,7 @@ export class HaSelect extends SelectBase { | ||||
|         direction: var(--direction); | ||||
|       } | ||||
|       .mdc-select .mdc-select__anchor { | ||||
|         padding-inline-start: 12px; | ||||
|         padding-inline-start: var(--ha-space-4); | ||||
|         padding-inline-end: 0px; | ||||
|         direction: var(--direction); | ||||
|       } | ||||
| @@ -158,7 +158,10 @@ export class HaSelect extends SelectBase { | ||||
|         padding-inline-end: var(--select-selected-text-padding-end, 0px); | ||||
|       } | ||||
|       :host([clearable]) .mdc-select__selected-text-container { | ||||
|         padding-inline-end: var(--select-selected-text-padding-end, 12px); | ||||
|         padding-inline-end: var( | ||||
|           --select-selected-text-padding-end, | ||||
|           var(--ha-space-4) | ||||
|         ); | ||||
|       } | ||||
|       ha-icon-button { | ||||
|         position: absolute; | ||||
|   | ||||
| @@ -1,122 +0,0 @@ | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import type { BackgroundSelector } from "../../data/selector"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import "../ha-picture-upload"; | ||||
| import "../ha-alert"; | ||||
| import type { HaPictureUpload } from "../ha-picture-upload"; | ||||
| import { URL_PREFIX } from "../../data/image_upload"; | ||||
|  | ||||
| @customElement("ha-selector-background") | ||||
| export class HaBackgroundSelector extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property() public value?: any; | ||||
|  | ||||
|   @property({ attribute: false }) public selector!: BackgroundSelector; | ||||
|  | ||||
|   @property({ type: Boolean }) public disabled = false; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = true; | ||||
|  | ||||
|   @state() private yamlBackground = false; | ||||
|  | ||||
|   protected updated(changedProps) { | ||||
|     super.updated(changedProps); | ||||
|  | ||||
|     if (changedProps.has("value")) { | ||||
|       this.yamlBackground = !!this.value && !this.value.startsWith(URL_PREFIX); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <div> | ||||
|         ${this.yamlBackground | ||||
|           ? html` | ||||
|               <div class="value"> | ||||
|                 <img | ||||
|                   src=${this.value} | ||||
|                   alt=${this.hass.localize( | ||||
|                     "ui.components.picture-upload.current_image_alt" | ||||
|                   )} | ||||
|                 /> | ||||
|               </div> | ||||
|               <ha-alert alert-type="info"> | ||||
|                 ${this.hass.localize( | ||||
|                   `ui.components.selectors.background.yaml_info` | ||||
|                 )} | ||||
|                 <ha-button slot="action" @click=${this._clearValue}> | ||||
|                   ${this.hass.localize( | ||||
|                     `ui.components.picture-upload.clear_picture` | ||||
|                   )} | ||||
|                 </ha-button> | ||||
|               </ha-alert> | ||||
|             ` | ||||
|           : html` | ||||
|               <ha-picture-upload | ||||
|                 .hass=${this.hass} | ||||
|                 .value=${this.value?.startsWith(URL_PREFIX) ? this.value : null} | ||||
|                 .original=${!!this.selector.background?.original} | ||||
|                 .cropOptions=${this.selector.background?.crop} | ||||
|                 select-media | ||||
|                 @change=${this._pictureChanged} | ||||
|               ></ha-picture-upload> | ||||
|             `} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _pictureChanged(ev) { | ||||
|     const value = (ev.target as HaPictureUpload).value; | ||||
|  | ||||
|     fireEvent(this, "value-changed", { value: value ?? undefined }); | ||||
|   } | ||||
|  | ||||
|   private _clearValue() { | ||||
|     fireEvent(this, "value-changed", { value: undefined }); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|       position: relative; | ||||
|     } | ||||
|     ha-picture-upload { | ||||
|       background-color: var(--primary-background-color); | ||||
|       border-radius: var(--file-upload-image-border-radius); | ||||
|     } | ||||
|     div { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|     ha-button { | ||||
|       white-space: nowrap; | ||||
|       --mdc-theme-primary: var(--primary-color); | ||||
|     } | ||||
|     .value { | ||||
|       width: 100%; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: center; | ||||
|     } | ||||
|     img { | ||||
|       max-width: 100%; | ||||
|       max-height: 200px; | ||||
|       margin-bottom: 4px; | ||||
|       border-radius: var(--file-upload-image-border-radius); | ||||
|       transition: opacity 0.3s; | ||||
|       opacity: var(--picture-opacity, 1); | ||||
|     } | ||||
|     img:hover { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-selector-background": HaBackgroundSelector; | ||||
|   } | ||||
| } | ||||
| @@ -36,6 +36,8 @@ export class HaDeviceSelector extends LitElement { | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @property() public placeholder?: string; | ||||
|  | ||||
|   @property({ type: Boolean }) public disabled = false; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = true; | ||||
| @@ -102,6 +104,7 @@ export class HaDeviceSelector extends LitElement { | ||||
|           .entityFilter=${this.selector.device?.entity | ||||
|             ? this._filterEntities | ||||
|             : undefined} | ||||
|           .placeholder=${this.placeholder} | ||||
|           .disabled=${this.disabled} | ||||
|           .required=${this.required} | ||||
|           allow-custom-entity | ||||
|   | ||||
							
								
								
									
										50
									
								
								src/components/ha-selector/ha-selector-entity-name.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/components/ha-selector/ha-selector-entity-name.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| import { html, LitElement } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
| import type { EntityNameSelector } from "../../data/selector"; | ||||
| import { SubscribeMixin } from "../../mixins/subscribe-mixin"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import "../entity/ha-entity-name-picker"; | ||||
|  | ||||
| @customElement("ha-selector-entity_name") | ||||
| export class HaSelectorEntityName extends SubscribeMixin(LitElement) { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: false }) public selector!: EntityNameSelector; | ||||
|  | ||||
|   @property() public value?: string | string[]; | ||||
|  | ||||
|   @property() public label?: string; | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @property({ type: Boolean }) public disabled = false; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = true; | ||||
|  | ||||
|   @property({ attribute: false }) public context?: { | ||||
|     entity?: string; | ||||
|   }; | ||||
|  | ||||
|   protected render() { | ||||
|     const value = this.value ?? this.selector.entity_name?.default_name; | ||||
|  | ||||
|     return html` | ||||
|       <ha-entity-name-picker | ||||
|         .hass=${this.hass} | ||||
|         .entityId=${this.selector.entity_name?.entity_id || | ||||
|         this.context?.entity} | ||||
|         .value=${value} | ||||
|         .label=${this.label} | ||||
|         .helper=${this.helper} | ||||
|         .disabled=${this.disabled} | ||||
|         .required=${this.required} | ||||
|       ></ha-entity-name-picker> | ||||
|     `; | ||||
|   } | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-selector-entity_name": HaSelectorEntityName; | ||||
|   } | ||||
| } | ||||
| @@ -29,6 +29,8 @@ export class HaEntitySelector extends LitElement { | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @property() public placeholder?: any; | ||||
|  | ||||
|   @property({ type: Boolean }) public disabled = false; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = true; | ||||
| @@ -69,6 +71,7 @@ export class HaEntitySelector extends LitElement { | ||||
|         .excludeEntities=${this.selector.entity?.exclude_entities} | ||||
|         .entityFilter=${this._filterEntities} | ||||
|         .createDomains=${this._createDomains} | ||||
|         .placeholder=${this.placeholder} | ||||
|         .disabled=${this.disabled} | ||||
|         .required=${this.required} | ||||
|         allow-custom-entity | ||||
| @@ -86,6 +89,7 @@ export class HaEntitySelector extends LitElement { | ||||
|         .reorder=${this.selector.entity.reorder ?? false} | ||||
|         .entityFilter=${this._filterEntities} | ||||
|         .createDomains=${this._createDomains} | ||||
|         .placeholder=${this.placeholder} | ||||
|         .disabled=${this.disabled} | ||||
|         .required=${this.required} | ||||
|       ></ha-entities-picker> | ||||
|   | ||||
| @@ -1,152 +0,0 @@ | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import type { ImageSelector } from "../../data/selector"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import "../ha-icon-button"; | ||||
| import "../ha-textarea"; | ||||
| import "../ha-textfield"; | ||||
| import "../ha-picture-upload"; | ||||
| import "../ha-radio"; | ||||
| import "../ha-formfield"; | ||||
| import type { HaPictureUpload } from "../ha-picture-upload"; | ||||
| import { URL_PREFIX } from "../../data/image_upload"; | ||||
|  | ||||
| @customElement("ha-selector-image") | ||||
| export class HaImageSelector extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property() public value?: any; | ||||
|  | ||||
|   @property() public name?: string; | ||||
|  | ||||
|   @property() public label?: string; | ||||
|  | ||||
|   @property() public placeholder?: string; | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @property({ attribute: false }) public selector!: ImageSelector; | ||||
|  | ||||
|   @property({ type: Boolean }) public disabled = false; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = true; | ||||
|  | ||||
|   @state() private showUpload = false; | ||||
|  | ||||
|   protected firstUpdated(changedProps): void { | ||||
|     super.firstUpdated(changedProps); | ||||
|  | ||||
|     if (!this.value || this.value.startsWith(URL_PREFIX)) { | ||||
|       this.showUpload = true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <div> | ||||
|         <label> | ||||
|           ${this.hass.localize( | ||||
|             "ui.components.selectors.image.select_image_with_label", | ||||
|             { | ||||
|               label: | ||||
|                 this.label || | ||||
|                 this.hass.localize("ui.components.selectors.image.image"), | ||||
|             } | ||||
|           )} | ||||
|           <ha-formfield | ||||
|             .label=${this.hass.localize("ui.components.selectors.image.upload")} | ||||
|           > | ||||
|             <ha-radio | ||||
|               name="mode" | ||||
|               value="upload" | ||||
|               .checked=${this.showUpload} | ||||
|               @change=${this._radioGroupPicked} | ||||
|             ></ha-radio> | ||||
|           </ha-formfield> | ||||
|           <ha-formfield | ||||
|             .label=${this.hass.localize("ui.components.selectors.image.url")} | ||||
|           > | ||||
|             <ha-radio | ||||
|               name="mode" | ||||
|               value="url" | ||||
|               .checked=${!this.showUpload} | ||||
|               @change=${this._radioGroupPicked} | ||||
|             ></ha-radio> | ||||
|           </ha-formfield> | ||||
|         </label> | ||||
|         ${!this.showUpload | ||||
|           ? html` | ||||
|               <ha-textfield | ||||
|                 .name=${this.name} | ||||
|                 .value=${this.value || ""} | ||||
|                 .placeholder=${this.placeholder || ""} | ||||
|                 .helper=${this.helper} | ||||
|                 helperPersistent | ||||
|                 .disabled=${this.disabled} | ||||
|                 @input=${this._handleChange} | ||||
|                 .label=${this.label || ""} | ||||
|                 .required=${this.required} | ||||
|               ></ha-textfield> | ||||
|             ` | ||||
|           : html` | ||||
|               <ha-picture-upload | ||||
|                 .hass=${this.hass} | ||||
|                 .value=${this.value?.startsWith(URL_PREFIX) ? this.value : null} | ||||
|                 .original=${this.selector.image?.original} | ||||
|                 .cropOptions=${this.selector.image?.crop} | ||||
|                 select-media | ||||
|                 @change=${this._pictureChanged} | ||||
|               ></ha-picture-upload> | ||||
|             `} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _radioGroupPicked(ev): void { | ||||
|     this.showUpload = ev.target.value === "upload"; | ||||
|   } | ||||
|  | ||||
|   private _pictureChanged(ev) { | ||||
|     const value = (ev.target as HaPictureUpload).value; | ||||
|  | ||||
|     fireEvent(this, "value-changed", { value: value ?? undefined }); | ||||
|   } | ||||
|  | ||||
|   private _handleChange(ev) { | ||||
|     let value = ev.target.value; | ||||
|     if (this.value === value) { | ||||
|       return; | ||||
|     } | ||||
|     if (value === "" && !this.required) { | ||||
|       value = undefined; | ||||
|     } | ||||
|  | ||||
|     fireEvent(this, "value-changed", { value }); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|       position: relative; | ||||
|     } | ||||
|     div { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|     label { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|     ha-textarea, | ||||
|     ha-textfield { | ||||
|       width: 100%; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-selector-image": HaImageSelector; | ||||
|   } | ||||
| } | ||||
| @@ -19,6 +19,7 @@ import "../ha-form/ha-form"; | ||||
| import type { SchemaUnion } from "../ha-form/types"; | ||||
| import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import "../ha-picture-upload"; | ||||
|  | ||||
| const MANUAL_SCHEMA = [ | ||||
|   { name: "media_content_id", required: false, selector: { text: {} } }, | ||||
| @@ -105,6 +106,18 @@ export class HaMediaSelector extends LitElement { | ||||
|       (stateObj && | ||||
|         supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)); | ||||
|  | ||||
|     if (this.selector.media?.image_upload && !this.value) { | ||||
|       return html`${this.label ? html`<label>${this.label}</label>` : nothing} | ||||
|         <ha-picture-upload | ||||
|           .hass=${this.hass} | ||||
|           .value=${null} | ||||
|           .contentIdHelper=${this.selector.media?.content_id_helper} | ||||
|           select-media | ||||
|           full-media | ||||
|           @media-picked=${this._pictureUploadMediaPicked} | ||||
|         ></ha-picture-upload>`; | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       ${this._hasAccept || | ||||
|       (this._contextEntities && this._contextEntities.length <= 1) | ||||
| @@ -129,6 +142,7 @@ export class HaMediaSelector extends LitElement { | ||||
|           `} | ||||
|       ${!supportsBrowse | ||||
|         ? html` | ||||
|             ${this.label ? html`<label>${this.label}</label>` : nothing} | ||||
|             <ha-alert> | ||||
|               ${this.hass.localize( | ||||
|                 "ui.components.selectors.media.browse_not_supported" | ||||
| @@ -142,7 +156,7 @@ export class HaMediaSelector extends LitElement { | ||||
|               .computeHelper=${this._computeHelperCallback} | ||||
|             ></ha-form> | ||||
|           ` | ||||
|         : html` | ||||
|         : html`${this.label ? html`<label>${this.label}</label>` : nothing} | ||||
|             <ha-card | ||||
|               outlined | ||||
|               tabindex="0" | ||||
| @@ -203,7 +217,20 @@ export class HaMediaSelector extends LitElement { | ||||
|                 </div> | ||||
|               </div> | ||||
|             </ha-card> | ||||
|           `} | ||||
|             ${this.selector.media?.clearable | ||||
|               ? html`<div> | ||||
|                   <ha-button | ||||
|                     appearance="plain" | ||||
|                     size="small" | ||||
|                     variant="danger" | ||||
|                     @click=${this._clearValue} | ||||
|                   > | ||||
|                     ${this.hass.localize( | ||||
|                       "ui.components.picture-upload.clear_picture" | ||||
|                     )} | ||||
|                   </ha-button> | ||||
|                 </div>` | ||||
|               : nothing}`} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
| @@ -248,6 +275,8 @@ export class HaMediaSelector extends LitElement { | ||||
|       accept: this.selector.media?.accept, | ||||
|       defaultId: this.value?.media_content_id, | ||||
|       defaultType: this.value?.media_content_type, | ||||
|       hideContentType: this.selector.media?.hide_content_type, | ||||
|       contentIdHelper: this.selector.media?.content_id_helper, | ||||
|       mediaPickedCallback: (pickedMedia: MediaPickedEvent) => { | ||||
|         fireEvent(this, "value-changed", { | ||||
|           value: { | ||||
| @@ -289,6 +318,31 @@ export class HaMediaSelector extends LitElement { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _pictureUploadMediaPicked(ev) { | ||||
|     const pickedMedia = ev.detail as MediaPickedEvent; | ||||
|     fireEvent(this, "value-changed", { | ||||
|       value: { | ||||
|         ...this.value, | ||||
|         media_content_id: pickedMedia.item.media_content_id, | ||||
|         media_content_type: pickedMedia.item.media_content_type, | ||||
|         metadata: { | ||||
|           title: pickedMedia.item.title, | ||||
|           thumbnail: pickedMedia.item.thumbnail, | ||||
|           media_class: pickedMedia.item.media_class, | ||||
|           children_media_class: pickedMedia.item.children_media_class, | ||||
|           navigateIds: pickedMedia.navigateIds?.map((id) => ({ | ||||
|             media_content_type: id.media_content_type, | ||||
|             media_content_id: id.media_content_id, | ||||
|           })), | ||||
|         }, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _clearValue() { | ||||
|     fireEvent(this, "value-changed", { value: undefined }); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     ha-entity-picker { | ||||
|       display: block; | ||||
|   | ||||
| @@ -1,4 +1,9 @@ | ||||
| import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js"; | ||||
| import { | ||||
|   mdiClose, | ||||
|   mdiDelete, | ||||
|   mdiDragHorizontalVariant, | ||||
|   mdiPencil, | ||||
| } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing, type PropertyValues } from "lit"; | ||||
| import { customElement, property, query } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| @@ -47,9 +52,10 @@ export class HaObjectSelector extends LitElement { | ||||
|     const translationKey = this.selector.object?.translation_key; | ||||
|  | ||||
|     if (this.localizeValue && translationKey) { | ||||
|       const label = this.localizeValue( | ||||
|         `${translationKey}.fields.${schema.name}` | ||||
|       ); | ||||
|       const label = | ||||
|         this.localizeValue(`${translationKey}.fields.${schema.name}.name`) || | ||||
|         // Fallback for backward compatibility | ||||
|         this.localizeValue(`${translationKey}.fields.${schema.name}`); | ||||
|       if (label) { | ||||
|         return label; | ||||
|       } | ||||
| @@ -57,6 +63,20 @@ export class HaObjectSelector extends LitElement { | ||||
|     return this.selector.object?.fields?.[schema.name]?.label || schema.name; | ||||
|   }; | ||||
|  | ||||
|   private _computeHelper = (schema: HaFormSchema): string => { | ||||
|     const translationKey = this.selector.object?.translation_key; | ||||
|  | ||||
|     if (this.localizeValue && translationKey) { | ||||
|       const helper = this.localizeValue( | ||||
|         `${translationKey}.fields.${schema.name}.description` | ||||
|       ); | ||||
|       if (helper) { | ||||
|         return helper; | ||||
|       } | ||||
|     } | ||||
|     return this.selector.object?.fields?.[schema.name]?.description || ""; | ||||
|   }; | ||||
|  | ||||
|   private _renderItem(item: any, index: number) { | ||||
|     const labelField = | ||||
|       this.selector.object!.label_field || | ||||
| @@ -92,7 +112,7 @@ export class HaObjectSelector extends LitElement { | ||||
|           ? html` | ||||
|               <ha-svg-icon | ||||
|                 class="handle" | ||||
|                 .path=${mdiDrag} | ||||
|                 .path=${mdiDragHorizontalVariant} | ||||
|                 slot="start" | ||||
|               ></ha-svg-icon> | ||||
|             ` | ||||
| @@ -209,6 +229,7 @@ export class HaObjectSelector extends LitElement { | ||||
|       schema: this._schema(this.selector), | ||||
|       data: {}, | ||||
|       computeLabel: this._computeLabel, | ||||
|       computeHelper: this._computeHelper, | ||||
|       submitText: this.hass.localize("ui.common.add"), | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { mdiDrag } from "@mdi/js"; | ||||
| import { mdiDragHorizontalVariant } from "@mdi/js"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property, query } from "lit/decorators"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| @@ -197,7 +197,7 @@ export class HaSelectSelector extends LitElement { | ||||
|                             ? html` | ||||
|                                 <ha-svg-icon | ||||
|                                   slot="icon" | ||||
|                                   .path=${mdiDrag} | ||||
|                                   .path=${mdiDragHorizontalVariant} | ||||
|                                 ></ha-svg-icon> | ||||
|                               ` | ||||
|                             : nothing} | ||||
|   | ||||
| @@ -36,7 +36,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) { | ||||
|         .helper=${this.helper} | ||||
|         .disabled=${this.disabled} | ||||
|         .required=${this.required} | ||||
|         .allowName=${this.selector.ui_state_content?.allow_name} | ||||
|         .allowName=${this.selector.ui_state_content?.allow_name || false} | ||||
|       ></ha-entity-state-content-picker> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -29,12 +29,11 @@ const LOAD_ELEMENTS = { | ||||
|   device: () => import("./ha-selector-device"), | ||||
|   duration: () => import("./ha-selector-duration"), | ||||
|   entity: () => import("./ha-selector-entity"), | ||||
|   entity_name: () => import("./ha-selector-entity-name"), | ||||
|   statistic: () => import("./ha-selector-statistic"), | ||||
|   file: () => import("./ha-selector-file"), | ||||
|   floor: () => import("./ha-selector-floor"), | ||||
|   label: () => import("./ha-selector-label"), | ||||
|   image: () => import("./ha-selector-image"), | ||||
|   background: () => import("./ha-selector-background"), | ||||
|   language: () => import("./ha-selector-language"), | ||||
|   navigation: () => import("./ha-selector-navigation"), | ||||
|   number: () => import("./ha-selector-number"), | ||||
|   | ||||
| @@ -53,7 +53,7 @@ class HaServicePicker extends LitElement { | ||||
|     item, | ||||
|     { index } | ||||
|   ) => html` | ||||
|     <ha-combo-box-item type="button" border-top .borderTop=${index !== 0}> | ||||
|     <ha-combo-box-item type="button" .borderTop=${index !== 0}> | ||||
|       <ha-service-icon | ||||
|         slot="start" | ||||
|         .hass=${this.hass} | ||||
| @@ -76,34 +76,42 @@ class HaServicePicker extends LitElement { | ||||
|     </ha-combo-box-item> | ||||
|   `; | ||||
|  | ||||
|   private _valueRenderer: PickerValueRenderer = (value) => { | ||||
|     const serviceId = value; | ||||
|     const [domain, service] = serviceId.split("."); | ||||
|   private _valueRenderer = memoizeOne( | ||||
|     ( | ||||
|       localize: LocalizeFunc, | ||||
|       services: HomeAssistant["services"] | ||||
|     ): PickerValueRenderer => | ||||
|       (value) => { | ||||
|         const serviceId = value; | ||||
|         const [domain, service] = serviceId.split("."); | ||||
|  | ||||
|     if (!this.hass.services[domain]?.[service]) { | ||||
|       return html` | ||||
|         <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon> | ||||
|         <span slot="headline">${value}</span> | ||||
|       `; | ||||
|     } | ||||
|         if (!services[domain]?.[service]) { | ||||
|           return html` | ||||
|             <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon> | ||||
|             <span slot="headline">${value}</span> | ||||
|           `; | ||||
|         } | ||||
|  | ||||
|     const serviceName = | ||||
|       this.hass.localize(`component.${domain}.services.${service}.name`) || | ||||
|       this.hass.services[domain][service].name || | ||||
|       service; | ||||
|         const serviceName = | ||||
|           localize(`component.${domain}.services.${service}.name`) || | ||||
|           services[domain][service].name || | ||||
|           service; | ||||
|  | ||||
|     return html` | ||||
|       <ha-service-icon | ||||
|         slot="start" | ||||
|         .hass=${this.hass} | ||||
|         .service=${serviceId} | ||||
|       ></ha-service-icon> | ||||
|       <span slot="headline">${serviceName}</span> | ||||
|       ${this.showServiceId | ||||
|         ? html`<span slot="supporting-text" class="code">${serviceId}</span>` | ||||
|         : nothing} | ||||
|     `; | ||||
|   }; | ||||
|         return html` | ||||
|           <ha-service-icon | ||||
|             slot="start" | ||||
|             .hass=${this.hass} | ||||
|             .service=${serviceId} | ||||
|           ></ha-service-icon> | ||||
|           <span slot="headline">${serviceName}</span> | ||||
|           ${this.showServiceId | ||||
|             ? html`<span slot="supporting-text" class="code" | ||||
|                 >${serviceId}</span | ||||
|               >` | ||||
|             : nothing} | ||||
|         `; | ||||
|       } | ||||
|   ); | ||||
|  | ||||
|   protected render(): TemplateResult { | ||||
|     const placeholder = | ||||
| @@ -123,7 +131,10 @@ class HaServicePicker extends LitElement { | ||||
|         .value=${this.value} | ||||
|         .getItems=${this._getItems} | ||||
|         .rowRenderer=${this._rowRenderer} | ||||
|         .valueRenderer=${this._valueRenderer} | ||||
|         .valueRenderer=${this._valueRenderer( | ||||
|           this.hass.localize, | ||||
|           this.hass.services | ||||
|         )} | ||||
|         @value-changed=${this._valueChanged} | ||||
|       > | ||||
|       </ha-generic-picker> | ||||
| @@ -162,7 +173,9 @@ class HaServicePicker extends LitElement { | ||||
|             const description = | ||||
|               this.hass.localize( | ||||
|                 `component.${domain}.services.${service}.description` | ||||
|               ) || services[domain][service].description; | ||||
|               ) || | ||||
|               services[domain][service].description || | ||||
|               ""; | ||||
|  | ||||
|             items.push({ | ||||
|               id: serviceId, | ||||
|   | ||||
| @@ -29,6 +29,7 @@ import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { toggleAttribute } from "../common/dom/toggle_attribute"; | ||||
| import { stringCompare } from "../common/string/compare"; | ||||
| import { computeRTL } from "../common/util/compute_rtl"; | ||||
| import { throttle } from "../common/util/throttle"; | ||||
| import { subscribeFrontendUserData } from "../data/frontend"; | ||||
| import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; | ||||
| @@ -536,11 +537,17 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|   } | ||||
|  | ||||
|   private _renderUserItem(selectedPanel: string) { | ||||
|     const isRTL = computeRTL(this.hass); | ||||
|  | ||||
|     return html` | ||||
|       <ha-md-list-item | ||||
|         href="/profile" | ||||
|         type="link" | ||||
|         class="user ${selectedPanel === "profile" ? " selected" : ""}" | ||||
|         class=${classMap({ | ||||
|           user: true, | ||||
|           selected: selectedPanel === "profile", | ||||
|           rtl: isRTL, | ||||
|         })} | ||||
|         @mouseenter=${this._itemMouseEnter} | ||||
|         @mouseleave=${this._itemMouseLeave} | ||||
|       > | ||||
| @@ -666,7 +673,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|     tooltip.style.display = "block"; | ||||
|     tooltip.style.position = "fixed"; | ||||
|     tooltip.style.top = `${top}px`; | ||||
|     tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`; | ||||
|     tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, var(--ha-space-0)))`; | ||||
|   } | ||||
|  | ||||
|   private _hideTooltip() { | ||||
| @@ -705,13 +712,17 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           background-color: var(--sidebar-background-color); | ||||
|           width: 100%; | ||||
|           box-sizing: border-box; | ||||
|           padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px)); | ||||
|           padding-bottom: calc( | ||||
|             14px + var(--safe-area-inset-bottom, var(--ha-space-0)) | ||||
|           ); | ||||
|         } | ||||
|         .menu { | ||||
|           height: calc(var(--header-height) + var(--safe-area-inset-top, 0px)); | ||||
|           height: calc( | ||||
|             var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0)) | ||||
|           ); | ||||
|           box-sizing: border-box; | ||||
|           display: flex; | ||||
|           padding: 0 4px; | ||||
|           padding: 0 var(--ha-space-1); | ||||
|           border-bottom: 1px solid transparent; | ||||
|           white-space: nowrap; | ||||
|           font-weight: var(--ha-font-weight-normal); | ||||
| @@ -726,13 +737,17 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           ); | ||||
|           font-size: var(--ha-font-size-xl); | ||||
|           align-items: center; | ||||
|           padding-left: calc(4px + var(--safe-area-inset-left, 0px)); | ||||
|           padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px)); | ||||
|           padding-left: calc( | ||||
|             var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0)) | ||||
|           ); | ||||
|           padding-inline-start: calc( | ||||
|             var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0)) | ||||
|           ); | ||||
|           padding-inline-end: initial; | ||||
|           padding-top: var(--safe-area-inset-top, 0px); | ||||
|           padding-top: var(--safe-area-inset-top, var(--ha-space-0)); | ||||
|         } | ||||
|         :host([expanded]) .menu { | ||||
|           width: calc(256px + var(--safe-area-inset-left, 0px)); | ||||
|           width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0))); | ||||
|         } | ||||
|         :host([narrow][expanded]) .menu { | ||||
|           width: 100%; | ||||
| @@ -748,8 +763,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           display: none; | ||||
|         } | ||||
|         :host([narrow]) .title { | ||||
|           margin: 0; | ||||
|           padding: 0 16px; | ||||
|           margin: var(--ha-space-0); | ||||
|           padding: var(--ha-space-0) var(--ha-space-4); | ||||
|         } | ||||
|         :host([expanded]) .title { | ||||
|           display: initial; | ||||
| @@ -761,13 +776,16 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|         ha-fade-in, | ||||
|         ha-md-list { | ||||
|           height: calc( | ||||
|             100% - var(--header-height) - var(--safe-area-inset-top, 0px) - | ||||
|             100% - var(--header-height) - var( | ||||
|                 --safe-area-inset-top, | ||||
|                 var(--ha-space-0) | ||||
|               ) - | ||||
|               132px | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         ha-fade-in { | ||||
|           padding: 4px 0; | ||||
|           padding: var(--ha-space-1) var(--ha-space-0); | ||||
|           box-sizing: border-box; | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
| @@ -777,29 +795,29 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|         ha-md-list { | ||||
|           overflow-x: hidden; | ||||
|           background: none; | ||||
|           margin-left: var(--safe-area-inset-left, 0px); | ||||
|           margin-left: var(--safe-area-inset-left, var(--ha-space-0)); | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item { | ||||
|           flex-shrink: 0; | ||||
|           box-sizing: border-box; | ||||
|           margin: 4px; | ||||
|           margin: var(--ha-space-1); | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|           --md-list-item-one-line-container-height: 40px; | ||||
|           --md-list-item-one-line-container-height: var(--ha-space-10); | ||||
|           --md-list-item-top-space: 0; | ||||
|           --md-list-item-bottom-space: 0; | ||||
|           width: 48px; | ||||
|           width: var(--ha-space-12); | ||||
|           position: relative; | ||||
|           --md-list-item-label-text-color: var(--sidebar-text-color); | ||||
|           --md-list-item-leading-space: 12px; | ||||
|           --md-list-item-trailing-space: 12px; | ||||
|           --md-list-item-leading-icon-size: 24px; | ||||
|           --md-list-item-leading-space: var(--ha-space-3); | ||||
|           --md-list-item-trailing-space: var(--ha-space-3); | ||||
|           --md-list-item-leading-icon-size: var(--ha-space-6); | ||||
|         } | ||||
|         :host([expanded]) ha-md-list-item { | ||||
|           width: 248px; | ||||
|         } | ||||
|         :host([narrow][expanded]) ha-md-list-item { | ||||
|           width: calc(240px - var(--safe-area-inset-left, 0px)); | ||||
|           width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0))); | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item.selected { | ||||
| @@ -823,7 +841,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|  | ||||
|         ha-icon[slot="start"], | ||||
|         ha-svg-icon[slot="start"] { | ||||
|           width: 24px; | ||||
|           width: var(--ha-space-6); | ||||
|           flex-shrink: 0; | ||||
|           color: var(--sidebar-icon-color); | ||||
|         } | ||||
| @@ -856,8 +874,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
|           align-items: center; | ||||
|           min-width: 8px; | ||||
|           border-radius: var(--ha-border-radius-md); | ||||
|           min-width: var(--ha-space-2); | ||||
|           border-radius: var(--ha-border-radius-xl); | ||||
|           font-weight: var(--ha-font-weight-normal); | ||||
|           line-height: normal; | ||||
|           background-color: var(--accent-color); | ||||
| @@ -867,22 +885,26 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|  | ||||
|         ha-svg-icon + .badge { | ||||
|           position: absolute; | ||||
|           top: 4px; | ||||
|           top: var(--ha-space-1); | ||||
|           left: 26px; | ||||
|           border-radius: var(--ha-border-radius-md); | ||||
|           font-size: 0.65em; | ||||
|           line-height: var(--ha-line-height-expanded); | ||||
|           padding: 0 4px; | ||||
|           padding: var(--ha-space-0) var(--ha-space-1); | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item.user { | ||||
|           --md-list-item-leading-icon-size: 40px; | ||||
|           --md-list-item-leading-space: 4px; | ||||
|           --md-list-item-leading-icon-size: var(--ha-space-10); | ||||
|           --md-list-item-leading-space: var(--ha-space-1); | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item.user.rtl { | ||||
|           --md-list-item-leading-space: var(--ha-space-3); | ||||
|         } | ||||
|  | ||||
|         ha-user-badge { | ||||
|           flex-shrink: 0; | ||||
|           margin-right: -8px; | ||||
|           margin-right: calc(var(--ha-space-2) * -1); | ||||
|         } | ||||
|  | ||||
|         .spacer { | ||||
| @@ -894,7 +916,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           color: var(--sidebar-text-color); | ||||
|           font-size: var(--ha-font-size-m); | ||||
|           font-weight: var(--ha-font-weight-medium); | ||||
|           padding: 16px; | ||||
|           padding: var(--ha-space-4); | ||||
|           white-space: nowrap; | ||||
|         } | ||||
|  | ||||
| @@ -906,7 +928,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           white-space: nowrap; | ||||
|           color: var(--sidebar-background-color); | ||||
|           background-color: var(--sidebar-text-color); | ||||
|           padding: 4px; | ||||
|           padding: var(--ha-space-1); | ||||
|           font-weight: var(--ha-font-weight-medium); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -59,12 +59,33 @@ export class HaSlider extends Slider { | ||||
|           background-color: var(--ha-slider-thumb-color, var(--primary-color)); | ||||
|         } | ||||
|  | ||||
|         #thumb:after { | ||||
|           content: ""; | ||||
|           border-radius: 50%; | ||||
|           position: absolute; | ||||
|           width: calc(var(--thumb-width) * 2 + 8px); | ||||
|           height: calc(var(--thumb-height) * 2 + 8px); | ||||
|           left: calc(-50% - 4px); | ||||
|           top: calc(-50% - 4px); | ||||
|           cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         #slider:focus-visible:not(.disabled) #thumb, | ||||
|         #slider:focus-visible:not(.disabled) #thumb-min, | ||||
|         #slider:focus-visible:not(.disabled) #thumb-max { | ||||
|           outline: var(--wa-focus-ring); | ||||
|         } | ||||
|  | ||||
|         #track:after { | ||||
|           content: ""; | ||||
|           position: absolute; | ||||
|           top: calc(-50% - 4px); | ||||
|           left: 0; | ||||
|           width: 100%; | ||||
|           height: calc(var(--track-size) * 2 + 8px); | ||||
|           cursor: pointer; | ||||
|         } | ||||
|  | ||||
|         #indicator { | ||||
|           background-color: var( | ||||
|             --ha-slider-indicator-color, | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										344
									
								
								src/components/ha-wa-dialog.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										344
									
								
								src/components/ha-wa-dialog.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,344 @@ | ||||
| import "@home-assistant/webawesome/dist/components/dialog/dialog"; | ||||
| import { mdiClose } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { | ||||
|   customElement, | ||||
|   eventOptions, | ||||
|   property, | ||||
|   query, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import "./ha-dialog-header"; | ||||
| import "./ha-icon-button"; | ||||
|  | ||||
| export type DialogWidth = "small" | "medium" | "large" | "full"; | ||||
|  | ||||
| /** | ||||
|  * Home Assistant dialog component | ||||
|  * | ||||
|  * @element ha-wa-dialog | ||||
|  * @extends {LitElement} | ||||
|  * | ||||
|  * @summary | ||||
|  * A stylable dialog built using the `wa-dialog` component, providing a standardized header (ha-dialog-header), | ||||
|  * body, and footer (preferably using `ha-dialog-footer`) with slots | ||||
|  * | ||||
|  * You can open and close the dialog declaratively by using the `data-dialog="close"` attribute. | ||||
|  * @see https://webawesome.com/docs/components/dialog/#opening-and-closing-dialogs-declaratively | ||||
|  * | ||||
|  * @slot header - Replace the entire header area. | ||||
|  * @slot headerNavigationIcon - Leading header action (e.g. close/back button). | ||||
|  * @slot headerActionItems - Trailing header actions (e.g. buttons, menus). | ||||
|  * @slot - Dialog content body. | ||||
|  * @slot footer - Dialog footer content. | ||||
|  * | ||||
|  * @csspart dialog - The dialog surface. | ||||
|  * @csspart header - The header container. | ||||
|  * @csspart body - The scrollable body container. | ||||
|  * @csspart footer - The footer container. | ||||
|  * | ||||
|  * @cssprop --dialog-content-padding - Padding for the dialog content sections. | ||||
|  * @cssprop --ha-dialog-show-duration - Show animation duration. | ||||
|  * @cssprop --ha-dialog-hide-duration - Hide animation duration. | ||||
|  * @cssprop --ha-dialog-surface-background - Dialog background color. | ||||
|  * @cssprop --ha-dialog-border-radius - Border radius of the dialog surface. | ||||
|  * @cssprop --dialog-z-index - Z-index for the dialog. | ||||
|  * @cssprop --dialog-surface-position - CSS position of the dialog surface. | ||||
|  * @cssprop --dialog-surface-margin-top - Top margin for the dialog surface. | ||||
|  * | ||||
|  * @attr {boolean} open - Controls the dialog open state. | ||||
|  * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium". | ||||
|  * @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false. | ||||
|  * @attr {string} header-title - Header title text when no custom title slot is provided. | ||||
|  * @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided. | ||||
|  * @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below". | ||||
|  * @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts. | ||||
|  * | ||||
|  * @event opened - Fired when the dialog is shown. | ||||
|  * @event closed - Fired after the dialog is hidden. | ||||
|  * | ||||
|  * @remarks | ||||
|  * **Focus Management:** | ||||
|  * To automatically focus an element when the dialog opens, add the `autofocus` attribute to it. | ||||
|  * Components with `delegatesFocus: true` (like `ha-form`) will forward focus to their first focusable child. | ||||
|  * Example: `<ha-form autofocus .schema=${schema}></ha-form>` | ||||
|  * | ||||
|  * @see https://github.com/home-assistant/frontend/issues/27143 | ||||
|  */ | ||||
| @customElement("ha-wa-dialog") | ||||
| export class HaWaDialog extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true }) | ||||
|   public open = false; | ||||
|  | ||||
|   @property({ type: String, reflect: true, attribute: "width" }) | ||||
|   public width: DialogWidth = "medium"; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" }) | ||||
|   public preventScrimClose = false; | ||||
|  | ||||
|   @property({ type: String, attribute: "header-title" }) | ||||
|   public headerTitle = ""; | ||||
|  | ||||
|   @property({ type: String, attribute: "header-subtitle" }) | ||||
|   public headerSubtitle = ""; | ||||
|  | ||||
|   @property({ type: String, attribute: "header-subtitle-position" }) | ||||
|   public headerSubtitlePosition: "above" | "below" = "below"; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true, attribute: "flexcontent" }) | ||||
|   public flexContent = false; | ||||
|  | ||||
|   @state() | ||||
|   private _open = false; | ||||
|  | ||||
|   @query(".body") public bodyContainer!: HTMLDivElement; | ||||
|  | ||||
|   @state() | ||||
|   private _bodyScrolled = false; | ||||
|  | ||||
|   protected updated( | ||||
|     changedProperties: Map<string | number | symbol, unknown> | ||||
|   ): void { | ||||
|     super.updated(changedProperties); | ||||
|  | ||||
|     if (changedProperties.has("open")) { | ||||
|       this._open = this.open; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <wa-dialog | ||||
|         .open=${this._open} | ||||
|         .lightDismiss=${!this.preventScrimClose} | ||||
|         without-header | ||||
|         @wa-show=${this._handleShow} | ||||
|         @wa-after-show=${this._handleAfterShow} | ||||
|         @wa-after-hide=${this._handleAfterHide} | ||||
|       > | ||||
|         <slot name="header"> | ||||
|           <ha-dialog-header | ||||
|             .subtitlePosition=${this.headerSubtitlePosition} | ||||
|             .showBorder=${this._bodyScrolled} | ||||
|           > | ||||
|             <slot name="headerNavigationIcon" slot="navigationIcon"> | ||||
|               <ha-icon-button | ||||
|                 data-dialog="close" | ||||
|                 .label=${this.hass?.localize("ui.common.close") ?? "Close"} | ||||
|                 .path=${mdiClose} | ||||
|               ></ha-icon-button> | ||||
|             </slot> | ||||
|             ${this.headerTitle | ||||
|               ? html`<span slot="title" class="title"> | ||||
|                   ${this.headerTitle} | ||||
|                 </span>` | ||||
|               : nothing} | ||||
|             ${this.headerSubtitle | ||||
|               ? html`<span slot="subtitle">${this.headerSubtitle}</span>` | ||||
|               : nothing} | ||||
|             <slot name="headerActionItems" slot="actionItems"></slot> | ||||
|           </ha-dialog-header> | ||||
|         </slot> | ||||
|         <div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}> | ||||
|           <slot></slot> | ||||
|         </div> | ||||
|         <slot name="footer" slot="footer"></slot> | ||||
|       </wa-dialog> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _handleShow = async () => { | ||||
|     this._open = true; | ||||
|     fireEvent(this, "opened"); | ||||
|  | ||||
|     await this.updateComplete; | ||||
|  | ||||
|     (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); | ||||
|   }; | ||||
|  | ||||
|   private _handleAfterShow = () => { | ||||
|     fireEvent(this, "after-show"); | ||||
|   }; | ||||
|  | ||||
|   private _handleAfterHide = () => { | ||||
|     this._open = false; | ||||
|     fireEvent(this, "closed"); | ||||
|   }; | ||||
|  | ||||
|   public disconnectedCallback(): void { | ||||
|     super.disconnectedCallback(); | ||||
|     this._open = false; | ||||
|   } | ||||
|  | ||||
|   @eventOptions({ passive: true }) | ||||
|   private _handleBodyScroll(ev: Event) { | ||||
|     this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       wa-dialog { | ||||
|         --full-width: var( | ||||
|           --ha-dialog-width-full, | ||||
|           min( | ||||
|             95vw, | ||||
|             calc( | ||||
|               100vw - var(--safe-area-inset-left, var(--ha-space-0)) - var( | ||||
|                   --safe-area-inset-right, | ||||
|                   var(--ha-space-0) | ||||
|                 ) | ||||
|             ) | ||||
|           ) | ||||
|         ); | ||||
|         --width: min(var(--ha-dialog-width-md, 580px), var(--full-width)); | ||||
|         --spacing: var(--dialog-content-padding, var(--ha-space-6)); | ||||
|         --show-duration: var(--ha-dialog-show-duration, 200ms); | ||||
|         --hide-duration: var(--ha-dialog-hide-duration, 200ms); | ||||
|         --ha-dialog-surface-background: var( | ||||
|           --card-background-color, | ||||
|           var(--ha-color-surface-default) | ||||
|         ); | ||||
|         --wa-color-surface-raised: var( | ||||
|           --ha-dialog-surface-background, | ||||
|           var(--card-background-color, var(--ha-color-surface-default)) | ||||
|         ); | ||||
|         --wa-panel-border-radius: var( | ||||
|           --ha-dialog-border-radius, | ||||
|           var(--ha-border-radius-3xl) | ||||
|         ); | ||||
|         max-width: var(--ha-dialog-max-width, 100vw); | ||||
|         max-width: var(--ha-dialog-max-width, 100svw); | ||||
|       } | ||||
|  | ||||
|       :host([width="small"]) wa-dialog { | ||||
|         --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width)); | ||||
|       } | ||||
|  | ||||
|       :host([width="large"]) wa-dialog { | ||||
|         --width: min(var(--ha-dialog-width-lg, 720px), var(--full-width)); | ||||
|       } | ||||
|  | ||||
|       :host([width="full"]) wa-dialog { | ||||
|         --width: var(--full-width); | ||||
|       } | ||||
|  | ||||
|       wa-dialog::part(dialog) { | ||||
|         min-width: var(--width, var(--full-width)); | ||||
|         max-width: var(--width, var(--full-width)); | ||||
|         max-height: var( | ||||
|           --ha-dialog-max-height, | ||||
|           calc(100% - var(--ha-space-20)) | ||||
|         ); | ||||
|         min-height: var(--ha-dialog-min-height); | ||||
|         position: var(--dialog-surface-position, relative); | ||||
|         margin-top: var(--dialog-surface-margin-top, auto); | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         overflow: hidden; | ||||
|       } | ||||
|  | ||||
|       @media all and (max-width: 450px), all and (max-height: 500px) { | ||||
|         :host { | ||||
|           --ha-dialog-border-radius: var(--ha-space-0); | ||||
|         } | ||||
|  | ||||
|         wa-dialog { | ||||
|           --full-width: var(--ha-dialog-width-full, 100vw); | ||||
|         } | ||||
|  | ||||
|         wa-dialog::part(dialog) { | ||||
|           min-height: var(--ha-dialog-min-height, 100vh); | ||||
|           min-height: var(--ha-dialog-min-height, 100svh); | ||||
|           max-height: var(--ha-dialog-max-height, 100vh); | ||||
|           max-height: var(--ha-dialog-max-height, 100svh); | ||||
|           padding-top: var(--safe-area-inset-top, var(--ha-space-0)); | ||||
|           padding-bottom: var(--safe-area-inset-bottom, var(--ha-space-0)); | ||||
|           padding-left: var(--safe-area-inset-left, var(--ha-space-0)); | ||||
|           padding-right: var(--safe-area-inset-right, var(--ha-space-0)); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .header-title-container { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|       } | ||||
|  | ||||
|       .header-title { | ||||
|         margin: 0; | ||||
|         margin-bottom: 0; | ||||
|         color: var(--ha-dialog-header-title-color, var(--primary-text-color)); | ||||
|         font-size: var( | ||||
|           --ha-dialog-header-title-font-size, | ||||
|           var(--ha-font-size-2xl) | ||||
|         ); | ||||
|         line-height: var( | ||||
|           --ha-dialog-header-title-line-height, | ||||
|           var(--ha-line-height-condensed) | ||||
|         ); | ||||
|         font-weight: var( | ||||
|           --ha-dialog-header-title-font-weight, | ||||
|           var(--ha-font-weight-normal) | ||||
|         ); | ||||
|         overflow: hidden; | ||||
|         text-overflow: ellipsis; | ||||
|         white-space: nowrap; | ||||
|         margin-right: var(--ha-space-3); | ||||
|       } | ||||
|  | ||||
|       wa-dialog::part(body) { | ||||
|         padding: 0; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         max-width: 100%; | ||||
|         overflow: hidden; | ||||
|       } | ||||
|  | ||||
|       .body { | ||||
|         position: var(--dialog-content-position, relative); | ||||
|         padding: 0 var(--dialog-content-padding, var(--ha-space-6)) | ||||
|           var(--dialog-content-padding, var(--ha-space-6)) | ||||
|           var(--dialog-content-padding, var(--ha-space-6)); | ||||
|         overflow: auto; | ||||
|         flex-grow: 1; | ||||
|       } | ||||
|       :host([flexcontent]) .body { | ||||
|         max-width: 100%; | ||||
|         flex: 1; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
|  | ||||
|       wa-dialog::part(footer) { | ||||
|         padding: var(--ha-space-0); | ||||
|       } | ||||
|  | ||||
|       ::slotted([slot="footer"]) { | ||||
|         display: flex; | ||||
|         padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4) | ||||
|           var(--ha-space-4); | ||||
|         gap: var(--ha-space-3); | ||||
|         justify-content: flex-end; | ||||
|         align-items: center; | ||||
|         width: 100%; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-wa-dialog": HaWaDialog; | ||||
|   } | ||||
|  | ||||
|   interface HASSDomEvents { | ||||
|     opened: undefined; | ||||
|     "after-show": undefined; | ||||
|     closed: undefined; | ||||
|   } | ||||
| } | ||||
| @@ -321,6 +321,10 @@ class HaWebRtcPlayer extends LitElement { | ||||
|     if (!this._remoteStream) { | ||||
|       return; | ||||
|     } | ||||
|     // If the track is audio and the player is muted, we do not add it to the stream. | ||||
|     if (event.track.kind === "audio" && this.muted) { | ||||
|       return; | ||||
|     } | ||||
|     this._remoteStream.addTrack(event.track); | ||||
|     if (!this.hasUpdated) { | ||||
|       await this.updateComplete; | ||||
|   | ||||
| @@ -167,6 +167,8 @@ class DialogMediaPlayerBrowse extends LitElement { | ||||
|           .accept=${this._params.accept} | ||||
|           .defaultId=${this._params.defaultId} | ||||
|           .defaultType=${this._params.defaultType} | ||||
|           .hideContentType=${this._params.hideContentType} | ||||
|           .contentIdHelper=${this._params.contentIdHelper} | ||||
|           @close-dialog=${this.closeDialog} | ||||
|           @media-picked=${this._mediaPicked} | ||||
|           @media-browsed=${this._mediaBrowsed} | ||||
|   | ||||
| @@ -19,8 +19,12 @@ class BrowseMediaManual extends LitElement { | ||||
|  | ||||
|   @property({ attribute: false }) public item!: MediaPlayerItemId; | ||||
|  | ||||
|   @property({ attribute: false }) public hideContentType = false; | ||||
|  | ||||
|   @property({ attribute: false }) public contentIdHelper?: string; | ||||
|  | ||||
|   private _schema = memoizeOne( | ||||
|     () => | ||||
|     (hideContentType: boolean) => | ||||
|       [ | ||||
|         { | ||||
|           name: "media_content_id", | ||||
| @@ -29,13 +33,17 @@ class BrowseMediaManual extends LitElement { | ||||
|             text: {}, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           name: "media_content_type", | ||||
|           required: false, | ||||
|           selector: { | ||||
|             text: {}, | ||||
|           }, | ||||
|         }, | ||||
|         ...(hideContentType | ||||
|           ? [] | ||||
|           : [ | ||||
|               { | ||||
|                 name: "media_content_type", | ||||
|                 required: false, | ||||
|                 selector: { | ||||
|                   text: {}, | ||||
|                 }, | ||||
|               }, | ||||
|             ]), | ||||
|       ] as const | ||||
|   ); | ||||
|  | ||||
| @@ -45,7 +53,7 @@ class BrowseMediaManual extends LitElement { | ||||
|         <div class="card-content"> | ||||
|           <ha-form | ||||
|             .hass=${this.hass} | ||||
|             .schema=${this._schema()} | ||||
|             .schema=${this._schema(this.hideContentType)} | ||||
|             .data=${this.item} | ||||
|             .computeLabel=${this._computeLabel} | ||||
|             .computeHelper=${this._computeHelper} | ||||
| @@ -69,13 +77,35 @@ class BrowseMediaManual extends LitElement { | ||||
|  | ||||
|   private _computeLabel = ( | ||||
|     entry: SchemaUnion<ReturnType<typeof this._schema>> | ||||
|   ): string => | ||||
|     this.hass.localize(`ui.components.selectors.media.${entry.name}`); | ||||
|   ): string => { | ||||
|     switch (entry.name) { | ||||
|       case "media_content_id": | ||||
|       case "media_content_type": | ||||
|         return this.hass.localize( | ||||
|           `ui.components.selectors.media.${entry.name}` | ||||
|         ); | ||||
|     } | ||||
|     return entry.name; | ||||
|   }; | ||||
|  | ||||
|   private _computeHelper = ( | ||||
|     entry: SchemaUnion<ReturnType<typeof this._schema>> | ||||
|   ): string => | ||||
|     this.hass.localize(`ui.components.selectors.media.${entry.name}_detail`); | ||||
|   ): string => { | ||||
|     switch (entry.name) { | ||||
|       case "media_content_id": | ||||
|         return ( | ||||
|           this.contentIdHelper || | ||||
|           this.hass.localize( | ||||
|             `ui.components.selectors.media.${entry.name}_detail` | ||||
|           ) | ||||
|         ); | ||||
|       case "media_content_type": | ||||
|         return this.hass.localize( | ||||
|           `ui.components.selectors.media.${entry.name}_detail` | ||||
|         ); | ||||
|     } | ||||
|     return ""; | ||||
|   }; | ||||
|  | ||||
|   private _mediaPicked() { | ||||
|     fireEvent(this, "manual-media-picked", { | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import { classMap } from "lit/directives/class-map"; | ||||
| import { styleMap } from "lit/directives/style-map"; | ||||
| import { until } from "lit/directives/until"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { slugify } from "../../common/string/slugify"; | ||||
| import { debounce } from "../../common/util/debounce"; | ||||
| import { isUnavailableState } from "../../data/entity"; | ||||
| import type { | ||||
| @@ -76,8 +77,8 @@ declare global { | ||||
| } | ||||
|  | ||||
| export interface MediaPlayerItemId { | ||||
|   media_content_id: string | undefined; | ||||
|   media_content_type: string | undefined; | ||||
|   media_content_id?: string | undefined; | ||||
|   media_content_type?: string | undefined; | ||||
| } | ||||
|  | ||||
| const MANUAL_ITEM: MediaPlayerItem = { | ||||
| @@ -113,6 +114,10 @@ export class HaMediaPlayerBrowse extends LitElement { | ||||
|  | ||||
|   @property({ attribute: false }) public defaultType?: string; | ||||
|  | ||||
|   @property({ attribute: false }) public hideContentType = false; | ||||
|  | ||||
|   @property({ attribute: false }) public contentIdHelper?: string; | ||||
|  | ||||
|   // @todo Consider reworking to eliminate need for attribute since it is manipulated internally | ||||
|   @property({ type: Boolean, reflect: true }) public narrow = false; | ||||
|  | ||||
| @@ -521,6 +526,8 @@ export class HaMediaPlayerBrowse extends LitElement { | ||||
|                         media_content_type: this.defaultType || "", | ||||
|                       }} | ||||
|                       .hass=${this.hass} | ||||
|                       .hideContentType=${this.hideContentType} | ||||
|                       .contentIdHelper=${this.contentIdHelper} | ||||
|                       @manual-media-picked=${this._manualPicked} | ||||
|                     ></ha-browse-media-manual>` | ||||
|                   : isTTSMediaSource(currentItem.media_content_id) | ||||
| @@ -687,10 +694,12 @@ export class HaMediaPlayerBrowse extends LitElement { | ||||
|                 ` | ||||
|               : ""} | ||||
|           </div> | ||||
|           <ha-tooltip .for="grid-${child.title}" distance="-4"> | ||||
|           <ha-tooltip .for="grid-${slugify(child.title)}" distance="-4"> | ||||
|             ${child.title} | ||||
|           </ha-tooltip> | ||||
|           <div .id="grid-${child.title}" class="title">${child.title}</div> | ||||
|           <div .id="grid-${slugify(child.title)}" class="title"> | ||||
|             ${child.title} | ||||
|           </div> | ||||
|         </ha-card> | ||||
|       </div> | ||||
|     `; | ||||
|   | ||||
| @@ -14,6 +14,8 @@ export interface MediaPlayerBrowseDialogParams { | ||||
|   accept?: string[]; | ||||
|   defaultId?: string; | ||||
|   defaultType?: string; | ||||
|   hideContentType?: boolean; | ||||
|   contentIdHelper?: string; | ||||
| } | ||||
|  | ||||
| export const showMediaBrowserDialog = ( | ||||
|   | ||||
							
								
								
									
										76
									
								
								src/components/target-picker/dialog/dialog-target-details.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/components/target-picker/dialog/dialog-target-details.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import { html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { fireEvent } from "../../../common/dom/fire_event"; | ||||
| import type { HassDialog } from "../../../dialogs/make-dialog-manager"; | ||||
| import type { HomeAssistant } from "../../../types"; | ||||
| import "../../ha-dialog-header"; | ||||
| import "../../ha-icon-button"; | ||||
| import "../../ha-icon-next"; | ||||
| import "../../ha-md-list"; | ||||
| import "../../ha-md-list-item"; | ||||
| import "../../ha-svg-icon"; | ||||
| import "../../ha-wa-dialog"; | ||||
| import "../ha-target-picker-item-row"; | ||||
| import type { TargetDetailsDialogParams } from "./show-dialog-target-details"; | ||||
|  | ||||
| @customElement("ha-dialog-target-details") | ||||
| class DialogTargetDetails extends LitElement implements HassDialog { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @state() private _params?: TargetDetailsDialogParams; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|  | ||||
|   public showDialog(params: TargetDetailsDialogParams): void { | ||||
|     this._params = params; | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   public closeDialog() { | ||||
|     this._opened = false; | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   private _dialogClosed() { | ||||
|     fireEvent(this, "dialog-closed", { dialog: this.localName }); | ||||
|     this._params = undefined; | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     if (!this._params) { | ||||
|       return nothing; | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       <ha-wa-dialog | ||||
|         .hass=${this.hass} | ||||
|         .open=${this._opened} | ||||
|         header-title=${this.hass.localize( | ||||
|           "ui.components.target-picker.target_details" | ||||
|         )} | ||||
|         header-subtitle=${`${this.hass.localize( | ||||
|           `ui.components.target-picker.type.${this._params.type}` | ||||
|         )}: | ||||
|             ${this._params.title}`} | ||||
|         @closed=${this._dialogClosed} | ||||
|       > | ||||
|         <ha-target-picker-item-row | ||||
|           .hass=${this.hass} | ||||
|           .type=${this._params.type} | ||||
|           .itemId=${this._params.itemId} | ||||
|           .deviceFilter=${this._params.deviceFilter} | ||||
|           .entityFilter=${this._params.entityFilter} | ||||
|           .includeDomains=${this._params.includeDomains} | ||||
|           .includeDeviceClasses=${this._params.includeDeviceClasses} | ||||
|           expand | ||||
|         ></ha-target-picker-item-row> | ||||
|       </ha-wa-dialog> | ||||
|     `; | ||||
|   } | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-dialog-target-details": DialogTargetDetails; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| import { fireEvent } from "../../../common/dom/fire_event"; | ||||
| import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity"; | ||||
| import type { TargetType } from "../../../data/target"; | ||||
| import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker"; | ||||
|  | ||||
| export type NewBackupType = "automatic" | "manual"; | ||||
|  | ||||
| export interface TargetDetailsDialogParams { | ||||
|   title: string; | ||||
|   type: TargetType; | ||||
|   itemId: string; | ||||
|   deviceFilter?: HaDevicePickerDeviceFilterFunc; | ||||
|   entityFilter?: HaEntityPickerEntityFilterFunc; | ||||
|   includeDomains?: string[]; | ||||
|   includeDeviceClasses?: string[]; | ||||
| } | ||||
|  | ||||
| export const loadTargetDetailsDialog = () => import("./dialog-target-details"); | ||||
|  | ||||
| export const showTargetDetailsDialog = ( | ||||
|   element: HTMLElement, | ||||
|   params: TargetDetailsDialogParams | ||||
| ) => | ||||
|   fireEvent(element, "show-dialog", { | ||||
|     dialogTag: "ha-dialog-target-details", | ||||
|     dialogImport: loadTargetDetailsDialog, | ||||
|     dialogParams: params, | ||||
|   }); | ||||
							
								
								
									
										113
									
								
								src/components/target-picker/ha-target-picker-item-group.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/components/target-picker/ha-target-picker-item-group.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
| import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; | ||||
| import type { TargetType, TargetTypeFloorless } from "../../data/target"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"; | ||||
| import "../ha-expansion-panel"; | ||||
| import "../ha-md-list"; | ||||
| import "./ha-target-picker-item-row"; | ||||
|  | ||||
| @customElement("ha-target-picker-item-group") | ||||
| export class HaTargetPickerItemGroup extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property() public type!: TargetTypeFloorless; | ||||
|  | ||||
|   @property({ attribute: false }) public items!: Partial< | ||||
|     Record<TargetType, string[]> | ||||
|   >; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true }) public collapsed = false; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public deviceFilter?: HaDevicePickerDeviceFilterFunc; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public entityFilter?: HaEntityPickerEntityFilterFunc; | ||||
|  | ||||
|   /** | ||||
|    * Show only targets with entities from specific domains. | ||||
|    * @type {Array} | ||||
|    * @attr include-domains | ||||
|    */ | ||||
|   @property({ type: Array, attribute: "include-domains" }) | ||||
|   public includeDomains?: string[]; | ||||
|  | ||||
|   /** | ||||
|    * Show only targets with entities of these device classes. | ||||
|    * @type {Array} | ||||
|    * @attr include-device-classes | ||||
|    */ | ||||
|   @property({ type: Array, attribute: "include-device-classes" }) | ||||
|   public includeDeviceClasses?: string[]; | ||||
|  | ||||
|   protected render() { | ||||
|     let count = 0; | ||||
|     Object.values(this.items).forEach((items) => { | ||||
|       if (items) { | ||||
|         count += items.length; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     return html`<ha-expansion-panel | ||||
|       .expanded=${!this.collapsed} | ||||
|       left-chevron | ||||
|       @expanded-changed=${this._expandedChanged} | ||||
|     > | ||||
|       <div slot="header" class="heading"> | ||||
|         ${this.hass.localize( | ||||
|           `ui.components.target-picker.selected.${this.type}`, | ||||
|           { | ||||
|             count, | ||||
|           } | ||||
|         )} | ||||
|       </div> | ||||
|       ${Object.entries(this.items).map(([type, items]) => | ||||
|         items | ||||
|           ? items.map( | ||||
|               (item) => | ||||
|                 html`<ha-target-picker-item-row | ||||
|                   .hass=${this.hass} | ||||
|                   .type=${type as TargetTypeFloorless} | ||||
|                   .itemId=${item} | ||||
|                   .deviceFilter=${this.deviceFilter} | ||||
|                   .entityFilter=${this.entityFilter} | ||||
|                   .includeDomains=${this.includeDomains} | ||||
|                   .includeDeviceClasses=${this.includeDeviceClasses} | ||||
|                 ></ha-target-picker-item-row>` | ||||
|             ) | ||||
|           : nothing | ||||
|       )} | ||||
|     </ha-expansion-panel>`; | ||||
|   } | ||||
|  | ||||
|   private _expandedChanged(ev: CustomEvent) { | ||||
|     this.collapsed = !ev.detail.expanded; | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|       --expansion-panel-content-padding: var(--ha-space-0); | ||||
|     } | ||||
|     ha-expansion-panel::part(summary) { | ||||
|       background-color: var(--ha-color-fill-neutral-quiet-resting); | ||||
|       padding: var(--ha-space-1) var(--ha-space-2); | ||||
|       font-weight: var(--ha-font-weight-bold); | ||||
|       color: var(--secondary-text-color); | ||||
|       display: flex; | ||||
|       justify-content: space-between; | ||||
|       min-height: unset; | ||||
|     } | ||||
|     ha-md-list { | ||||
|       padding: var(--ha-space-0); | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-target-picker-item-group": HaTargetPickerItemGroup; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										694
									
								
								src/components/target-picker/ha-target-picker-item-row.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										694
									
								
								src/components/target-picker/ha-target-picker-item-row.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,694 @@ | ||||
| import { consume } from "@lit/context"; | ||||
| import { | ||||
|   mdiClose, | ||||
|   mdiDevices, | ||||
|   mdiHome, | ||||
|   mdiLabel, | ||||
|   mdiTextureBox, | ||||
| } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing, type PropertyValues } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { computeAreaName } from "../../common/entity/compute_area_name"; | ||||
| import { | ||||
|   computeDeviceName, | ||||
|   computeDeviceNameDisplay, | ||||
| } from "../../common/entity/compute_device_name"; | ||||
| import { computeDomain } from "../../common/entity/compute_domain"; | ||||
| import { computeEntityName } from "../../common/entity/compute_entity_name"; | ||||
| import { getEntityContext } from "../../common/entity/context/get_entity_context"; | ||||
| import { computeRTL } from "../../common/util/compute_rtl"; | ||||
| import { getConfigEntry } from "../../data/config_entries"; | ||||
| import { labelsContext } from "../../data/context"; | ||||
| import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; | ||||
| import { domainToName } from "../../data/integration"; | ||||
| import type { LabelRegistryEntry } from "../../data/label_registry"; | ||||
| import { | ||||
|   areaMeetsFilter, | ||||
|   deviceMeetsFilter, | ||||
|   entityRegMeetsFilter, | ||||
|   extractFromTarget, | ||||
|   type ExtractFromTargetResult, | ||||
|   type ExtractFromTargetResultReferenced, | ||||
|   type TargetType, | ||||
| } from "../../data/target"; | ||||
| import { buttonLinkStyle } from "../../resources/styles"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import { brandsUrl } from "../../util/brands-url"; | ||||
| import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"; | ||||
| import { floorDefaultIconPath } from "../ha-floor-icon"; | ||||
| import "../ha-icon-button"; | ||||
| import "../ha-md-list"; | ||||
| import type { HaMdList } from "../ha-md-list"; | ||||
| import "../ha-md-list-item"; | ||||
| import type { HaMdListItem } from "../ha-md-list-item"; | ||||
| import "../ha-state-icon"; | ||||
| import "../ha-svg-icon"; | ||||
| import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details"; | ||||
|  | ||||
| @customElement("ha-target-picker-item-row") | ||||
| export class HaTargetPickerItemRow extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ reflect: true }) public type!: TargetType; | ||||
|  | ||||
|   @property({ attribute: "item-id" }) public itemId!: string; | ||||
|  | ||||
|   @property({ type: Boolean }) public expand = false; | ||||
|  | ||||
|   @property({ type: Boolean, attribute: "sub-entry", reflect: true }) | ||||
|   public subEntry = false; | ||||
|  | ||||
|   @property({ type: Boolean, attribute: "hide-context" }) | ||||
|   public hideContext = false; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public parentEntries?: ExtractFromTargetResultReferenced; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public deviceFilter?: HaDevicePickerDeviceFilterFunc; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public entityFilter?: HaEntityPickerEntityFilterFunc; | ||||
|  | ||||
|   /** | ||||
|    * Show only targets with entities from specific domains. | ||||
|    * @type {Array} | ||||
|    * @attr include-domains | ||||
|    */ | ||||
|   @property({ type: Array, attribute: "include-domains" }) | ||||
|   public includeDomains?: string[]; | ||||
|  | ||||
|   /** | ||||
|    * Show only targets with entities of these device classes. | ||||
|    * @type {Array} | ||||
|    * @attr include-device-classes | ||||
|    */ | ||||
|   @property({ type: Array, attribute: "include-device-classes" }) | ||||
|   public includeDeviceClasses?: string[]; | ||||
|  | ||||
|   @state() private _iconImg?: string; | ||||
|  | ||||
|   @state() private _domainName?: string; | ||||
|  | ||||
|   @state() private _entries?: ExtractFromTargetResult; | ||||
|  | ||||
|   @state() | ||||
|   @consume({ context: labelsContext, subscribe: true }) | ||||
|   _labelRegistry!: LabelRegistryEntry[]; | ||||
|  | ||||
|   @query("ha-md-list-item") public item?: HaMdListItem; | ||||
|  | ||||
|   @query("ha-md-list") public list?: HaMdList; | ||||
|  | ||||
|   @query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow; | ||||
|  | ||||
|   protected willUpdate(changedProps: PropertyValues) { | ||||
|     if (!this.subEntry && changedProps.has("itemId")) { | ||||
|       this._updateItemData(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     const { name, context, iconPath, fallbackIconPath, stateObject } = | ||||
|       this._itemData(this.type, this.itemId); | ||||
|  | ||||
|     const showEntities = this.type !== "entity"; | ||||
|  | ||||
|     const entries = this.parentEntries || this._entries; | ||||
|  | ||||
|     // Don't show sub entries that have no entities | ||||
|     if ( | ||||
|       this.subEntry && | ||||
|       this.type !== "entity" && | ||||
|       (!entries || entries.referenced_entities.length === 0) | ||||
|     ) { | ||||
|       return nothing; | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       <ha-md-list-item type="text"> | ||||
|         <div class="icon" slot="start"> | ||||
|           ${this.subEntry | ||||
|             ? html` | ||||
|                 <div class="horizontal-line-wrapper"> | ||||
|                   <div class="horizontal-line"></div> | ||||
|                 </div> | ||||
|               ` | ||||
|             : nothing} | ||||
|           ${iconPath | ||||
|             ? html`<ha-icon .icon=${iconPath}></ha-icon>` | ||||
|             : this._iconImg | ||||
|               ? html`<img | ||||
|                   alt=${this._domainName || ""} | ||||
|                   crossorigin="anonymous" | ||||
|                   referrerpolicy="no-referrer" | ||||
|                   src=${this._iconImg} | ||||
|                 />` | ||||
|               : fallbackIconPath | ||||
|                 ? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>` | ||||
|                 : stateObject | ||||
|                   ? html` | ||||
|                       <ha-state-icon | ||||
|                         .hass=${this.hass} | ||||
|                         .stateObj=${stateObject} | ||||
|                       > | ||||
|                       </ha-state-icon> | ||||
|                     ` | ||||
|                   : nothing} | ||||
|         </div> | ||||
|  | ||||
|         <div slot="headline">${name}</div> | ||||
|         ${context && !this.hideContext | ||||
|           ? html`<span slot="supporting-text">${context}</span>` | ||||
|           : nothing} | ||||
|         ${this._domainName && this.subEntry | ||||
|           ? html`<span slot="supporting-text" class="domain" | ||||
|               >${this._domainName}</span | ||||
|             >` | ||||
|           : nothing} | ||||
|         ${!this.subEntry && entries && showEntities | ||||
|           ? html` | ||||
|               <div slot="end" class="summary"> | ||||
|                 ${showEntities && | ||||
|                 !this.expand && | ||||
|                 entries?.referenced_entities.length | ||||
|                   ? html`<button class="main link" @click=${this._openDetails}> | ||||
|                       ${this.hass.localize( | ||||
|                         "ui.components.target-picker.entities_count", | ||||
|                         { | ||||
|                           count: entries?.referenced_entities.length, | ||||
|                         } | ||||
|                       )} | ||||
|                     </button>` | ||||
|                   : showEntities | ||||
|                     ? html`<span class="main"> | ||||
|                         ${this.hass.localize( | ||||
|                           "ui.components.target-picker.entities_count", | ||||
|                           { | ||||
|                             count: entries?.referenced_entities.length, | ||||
|                           } | ||||
|                         )} | ||||
|                       </span>` | ||||
|                     : nothing} | ||||
|               </div> | ||||
|             ` | ||||
|           : nothing} | ||||
|         ${!this.expand && !this.subEntry | ||||
|           ? html` | ||||
|               <ha-icon-button | ||||
|                 .path=${mdiClose} | ||||
|                 slot="end" | ||||
|                 @click=${this._removeItem} | ||||
|               ></ha-icon-button> | ||||
|             ` | ||||
|           : nothing} | ||||
|       </ha-md-list-item> | ||||
|       ${this.expand && entries && entries.referenced_entities | ||||
|         ? this._renderEntries() | ||||
|         : nothing} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderEntries() { | ||||
|     const entries = this.parentEntries || this._entries; | ||||
|  | ||||
|     let nextType: TargetType = | ||||
|       this.type === "floor" | ||||
|         ? "area" | ||||
|         : this.type === "area" | ||||
|           ? "device" | ||||
|           : "entity"; | ||||
|  | ||||
|     if (this.type === "label") { | ||||
|       if (entries?.referenced_areas.length) { | ||||
|         nextType = "area"; | ||||
|       } else if (entries?.referenced_devices.length) { | ||||
|         nextType = "device"; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const rows1 = | ||||
|       (nextType === "area" | ||||
|         ? entries?.referenced_areas | ||||
|         : nextType === "device" && this.type !== "label" | ||||
|           ? entries?.referenced_devices | ||||
|           : this.type !== "label" | ||||
|             ? entries?.referenced_entities | ||||
|             : []) || []; | ||||
|  | ||||
|     const devicesInAreas = [] as string[]; | ||||
|  | ||||
|     const rows1Entries = | ||||
|       nextType === "entity" | ||||
|         ? undefined | ||||
|         : rows1.map((rowItem) => { | ||||
|             const nextEntries = { | ||||
|               referenced_areas: [] as string[], | ||||
|               referenced_devices: [] as string[], | ||||
|               referenced_entities: [] as string[], | ||||
|             }; | ||||
|  | ||||
|             if (nextType === "area") { | ||||
|               nextEntries.referenced_devices = | ||||
|                 entries?.referenced_devices.filter( | ||||
|                   (device_id) => | ||||
|                     this.hass.devices?.[device_id]?.area_id === rowItem && | ||||
|                     entries?.referenced_entities.some( | ||||
|                       (entity_id) => | ||||
|                         this.hass.entities?.[entity_id]?.device_id === device_id | ||||
|                     ) | ||||
|                 ) || ([] as string[]); | ||||
|  | ||||
|               devicesInAreas.push(...nextEntries.referenced_devices); | ||||
|  | ||||
|               nextEntries.referenced_entities = | ||||
|                 entries?.referenced_entities.filter((entity_id) => { | ||||
|                   const entity = this.hass.entities[entity_id]; | ||||
|                   return ( | ||||
|                     entity.area_id === rowItem || | ||||
|                     !entity.device_id || | ||||
|                     nextEntries.referenced_devices.includes(entity.device_id) | ||||
|                   ); | ||||
|                 }) || ([] as string[]); | ||||
|  | ||||
|               return nextEntries; | ||||
|             } | ||||
|  | ||||
|             nextEntries.referenced_entities = | ||||
|               entries?.referenced_entities.filter( | ||||
|                 (entity_id) => | ||||
|                   this.hass.entities?.[entity_id]?.device_id === rowItem | ||||
|               ) || ([] as string[]); | ||||
|  | ||||
|             return nextEntries; | ||||
|           }); | ||||
|  | ||||
|     const entityRows = | ||||
|       this.type === "label" && entries | ||||
|         ? entries.referenced_entities.filter((entity_id) => { | ||||
|             const entity = this.hass.entities[entity_id]; | ||||
|             return ( | ||||
|               entity.labels.includes(this.itemId) && | ||||
|               !entries.referenced_devices.includes(entity.device_id || "") | ||||
|             ); | ||||
|           }) | ||||
|         : nextType === "device" && entries | ||||
|           ? entries.referenced_entities.filter( | ||||
|               (entity_id) => | ||||
|                 this.hass.entities[entity_id].area_id === this.itemId | ||||
|             ) | ||||
|           : []; | ||||
|  | ||||
|     const deviceRows = | ||||
|       this.type === "label" && entries | ||||
|         ? entries.referenced_devices.filter( | ||||
|             (device_id) => | ||||
|               !devicesInAreas.includes(device_id) && | ||||
|               this.hass.devices[device_id].labels.includes(this.itemId) | ||||
|           ) | ||||
|         : []; | ||||
|  | ||||
|     const deviceRowsEntries = | ||||
|       deviceRows.length === 0 | ||||
|         ? undefined | ||||
|         : deviceRows.map((device_id) => ({ | ||||
|             referenced_areas: [] as string[], | ||||
|             referenced_devices: [] as string[], | ||||
|             referenced_entities: | ||||
|               entries?.referenced_entities.filter( | ||||
|                 (entity_id) => | ||||
|                   this.hass.entities?.[entity_id]?.device_id === device_id | ||||
|               ) || ([] as string[]), | ||||
|           })); | ||||
|  | ||||
|     return html` | ||||
|       <div class="entries-tree"> | ||||
|         <div class="line-wrapper"> | ||||
|           <div class="line"></div> | ||||
|         </div> | ||||
|         <ha-md-list class="entries"> | ||||
|           ${rows1.map( | ||||
|             (itemId, index) => html` | ||||
|               <ha-target-picker-item-row | ||||
|                 sub-entry | ||||
|                 .hass=${this.hass} | ||||
|                 .type=${nextType} | ||||
|                 .itemId=${itemId} | ||||
|                 .parentEntries=${rows1Entries?.[index]} | ||||
|                 .hideContext=${this.hideContext || this.type !== "label"} | ||||
|                 expand | ||||
|               ></ha-target-picker-item-row> | ||||
|             ` | ||||
|           )} | ||||
|           ${deviceRows.map( | ||||
|             (itemId, index) => html` | ||||
|               <ha-target-picker-item-row | ||||
|                 sub-entry | ||||
|                 .hass=${this.hass} | ||||
|                 type="device" | ||||
|                 .itemId=${itemId} | ||||
|                 .parentEntries=${deviceRowsEntries?.[index]} | ||||
|                 .hideContext=${this.hideContext || this.type !== "label"} | ||||
|                 expand | ||||
|               ></ha-target-picker-item-row> | ||||
|             ` | ||||
|           )} | ||||
|           ${entityRows.map( | ||||
|             (itemId) => html` | ||||
|               <ha-target-picker-item-row | ||||
|                 sub-entry | ||||
|                 .hass=${this.hass} | ||||
|                 type="entity" | ||||
|                 .itemId=${itemId} | ||||
|                 .hideContext=${this.hideContext || this.type !== "label"} | ||||
|               ></ha-target-picker-item-row> | ||||
|             ` | ||||
|           )} | ||||
|         </ha-md-list> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private async _updateItemData() { | ||||
|     if (this.type === "entity") { | ||||
|       this._entries = undefined; | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       const entries = await extractFromTarget(this.hass, { | ||||
|         [`${this.type}_id`]: [this.itemId], | ||||
|       }); | ||||
|  | ||||
|       const hiddenAreaIds: string[] = []; | ||||
|       if (this.type === "floor" || this.type === "label") { | ||||
|         entries.referenced_areas = entries.referenced_areas.filter( | ||||
|           (area_id) => { | ||||
|             const area = this.hass.areas[area_id]; | ||||
|             if ( | ||||
|               (this.type === "floor" || area.labels.includes(this.itemId)) && | ||||
|               areaMeetsFilter( | ||||
|                 area, | ||||
|                 this.hass.devices, | ||||
|                 this.hass.entities, | ||||
|                 this.deviceFilter, | ||||
|                 this.includeDomains, | ||||
|                 this.includeDeviceClasses, | ||||
|                 this.hass.states, | ||||
|                 this.entityFilter | ||||
|               ) | ||||
|             ) { | ||||
|               return true; | ||||
|             } | ||||
|  | ||||
|             hiddenAreaIds.push(area_id); | ||||
|             return false; | ||||
|           } | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       const hiddenDeviceIds: string[] = []; | ||||
|       if ( | ||||
|         this.type === "floor" || | ||||
|         this.type === "area" || | ||||
|         this.type === "label" | ||||
|       ) { | ||||
|         entries.referenced_devices = entries.referenced_devices.filter( | ||||
|           (device_id) => { | ||||
|             const device = this.hass.devices[device_id]; | ||||
|             if ( | ||||
|               !hiddenAreaIds.includes(device.area_id || "") && | ||||
|               deviceMeetsFilter( | ||||
|                 device, | ||||
|                 this.hass.entities, | ||||
|                 this.deviceFilter, | ||||
|                 this.includeDomains, | ||||
|                 this.includeDeviceClasses, | ||||
|                 this.hass.states, | ||||
|                 this.entityFilter | ||||
|               ) | ||||
|             ) { | ||||
|               return true; | ||||
|             } | ||||
|  | ||||
|             hiddenDeviceIds.push(device_id); | ||||
|             return false; | ||||
|           } | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       entries.referenced_entities = entries.referenced_entities.filter( | ||||
|         (entity_id) => { | ||||
|           const entity = this.hass.entities[entity_id]; | ||||
|           if (hiddenDeviceIds.includes(entity.device_id || "")) { | ||||
|             return false; | ||||
|           } | ||||
|           if ( | ||||
|             (this.type === "area" && entity.area_id === this.itemId) || | ||||
|             (this.type === "floor" && | ||||
|               entity.area_id && | ||||
|               entries.referenced_areas.includes(entity.area_id)) || | ||||
|             (this.type === "label" && entity.labels.includes(this.itemId)) || | ||||
|             entries.referenced_devices.includes(entity.device_id || "") | ||||
|           ) { | ||||
|             return entityRegMeetsFilter( | ||||
|               entity, | ||||
|               this.type === "label", | ||||
|               this.includeDomains, | ||||
|               this.includeDeviceClasses, | ||||
|               this.hass.states, | ||||
|               this.entityFilter | ||||
|             ); | ||||
|           } | ||||
|           return false; | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       this._entries = entries; | ||||
|     } catch (e) { | ||||
|       // eslint-disable-next-line no-console | ||||
|       console.error("Failed to extract target", e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _itemData = memoizeOne((type: TargetType, item: string) => { | ||||
|     if (type === "floor") { | ||||
|       const floor = this.hass.floors?.[item]; | ||||
|       return { | ||||
|         name: floor?.name || item, | ||||
|         iconPath: floor?.icon, | ||||
|         fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome, | ||||
|       }; | ||||
|     } | ||||
|     if (type === "area") { | ||||
|       const area = this.hass.areas?.[item]; | ||||
|       return { | ||||
|         name: area?.name || item, | ||||
|         context: area.floor_id && this.hass.floors?.[area.floor_id]?.name, | ||||
|         iconPath: area?.icon, | ||||
|         fallbackIconPath: mdiTextureBox, | ||||
|       }; | ||||
|     } | ||||
|     if (type === "device") { | ||||
|       const device = this.hass.devices?.[item]; | ||||
|  | ||||
|       if (device.primary_config_entry) { | ||||
|         this._getDeviceDomain(device.primary_config_entry); | ||||
|       } | ||||
|  | ||||
|       return { | ||||
|         name: device ? computeDeviceNameDisplay(device, this.hass) : item, | ||||
|         context: device?.area_id && this.hass.areas?.[device.area_id]?.name, | ||||
|         fallbackIconPath: mdiDevices, | ||||
|       }; | ||||
|     } | ||||
|     if (type === "entity") { | ||||
|       this._setDomainName(computeDomain(item)); | ||||
|  | ||||
|       const stateObject = this.hass.states[item]; | ||||
|       const entityName = computeEntityName( | ||||
|         stateObject, | ||||
|         this.hass.entities, | ||||
|         this.hass.devices | ||||
|       ); | ||||
|       const { area, device } = getEntityContext( | ||||
|         stateObject, | ||||
|         this.hass.entities, | ||||
|         this.hass.devices, | ||||
|         this.hass.areas, | ||||
|         this.hass.floors | ||||
|       ); | ||||
|       const deviceName = device ? computeDeviceName(device) : undefined; | ||||
|       const areaName = area ? computeAreaName(area) : undefined; | ||||
|       const context = [areaName, entityName ? deviceName : undefined] | ||||
|         .filter(Boolean) | ||||
|         .join(computeRTL(this.hass) ? " ◂ " : " ▸ "); | ||||
|       return { | ||||
|         name: entityName || deviceName || item, | ||||
|         context, | ||||
|         stateObject, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // type label | ||||
|     const label = this._labelRegistry.find((lab) => lab.label_id === item); | ||||
|     return { | ||||
|       name: label?.name || item, | ||||
|       iconPath: label?.icon, | ||||
|       fallbackIconPath: mdiLabel, | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   private _setDomainName(domain: string) { | ||||
|     this._domainName = domainToName(this.hass.localize, domain); | ||||
|   } | ||||
|  | ||||
|   private _removeItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     fireEvent(this, "remove-target-item", { | ||||
|       type: this.type, | ||||
|       id: this.itemId, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private async _getDeviceDomain(configEntryId: string) { | ||||
|     try { | ||||
|       const data = await getConfigEntry(this.hass, configEntryId); | ||||
|       const domain = data.config_entry.domain; | ||||
|       this._iconImg = brandsUrl({ | ||||
|         domain: domain, | ||||
|         type: "icon", | ||||
|         darkOptimized: this.hass.themes?.darkMode, | ||||
|       }); | ||||
|  | ||||
|       this._setDomainName(domain); | ||||
|     } catch { | ||||
|       // failed to load config entry -> ignore | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _openDetails() { | ||||
|     showTargetDetailsDialog(this, { | ||||
|       title: this._itemData(this.type, this.itemId).name, | ||||
|       type: this.type, | ||||
|       itemId: this.itemId, | ||||
|       deviceFilter: this.deviceFilter, | ||||
|       entityFilter: this.entityFilter, | ||||
|       includeDomains: this.includeDomains, | ||||
|       includeDeviceClasses: this.includeDeviceClasses, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     buttonLinkStyle, | ||||
|     css` | ||||
|       :host { | ||||
|         --md-list-item-top-space: var(--ha-space-0); | ||||
|         --md-list-item-bottom-space: var(--ha-space-0); | ||||
|         --md-list-item-leading-space: var(--ha-space-2); | ||||
|         --md-list-item-trailing-space: var(--ha-space-2); | ||||
|         --md-list-item-two-line-container-height: 56px; | ||||
|       } | ||||
|  | ||||
|       :host([expand]:not([sub-entry])) ha-md-list-item { | ||||
|         border: 2px solid var(--ha-color-border-neutral-loud); | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-resting); | ||||
|         border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg)); | ||||
|       } | ||||
|  | ||||
|       state-badge { | ||||
|         color: var(--ha-color-on-neutral-quiet); | ||||
|       } | ||||
|  | ||||
|       .icon { | ||||
|         display: flex; | ||||
|       } | ||||
|  | ||||
|       img { | ||||
|         width: 24px; | ||||
|         height: 24px; | ||||
|       } | ||||
|       ha-icon-button { | ||||
|         --mdc-icon-button-size: 32px; | ||||
|       } | ||||
|       .summary { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: flex-end; | ||||
|         line-height: var(--ha-line-height-condensed); | ||||
|       } | ||||
|       :host([sub-entry]) .summary { | ||||
|         margin-right: var(--ha-space-12); | ||||
|       } | ||||
|       .summary .main { | ||||
|         font-weight: var(--ha-font-weight-medium); | ||||
|       } | ||||
|       .summary .secondary { | ||||
|         font-size: var(--ha-font-size-s); | ||||
|         color: var(--secondary-text-color); | ||||
|       } | ||||
|  | ||||
|       .entries-tree { | ||||
|         display: flex; | ||||
|         position: relative; | ||||
|       } | ||||
|  | ||||
|       .entries-tree .line-wrapper { | ||||
|         padding: var(--ha-space-5); | ||||
|       } | ||||
|  | ||||
|       .entries-tree .line-wrapper .line { | ||||
|         border-left: 2px dashed var(--divider-color); | ||||
|         height: calc(100% - 28px); | ||||
|         position: absolute; | ||||
|         top: 0; | ||||
|       } | ||||
|  | ||||
|       :host([sub-entry]) .entries-tree .line-wrapper .line { | ||||
|         height: calc(100% - 12px); | ||||
|         top: -18px; | ||||
|       } | ||||
|  | ||||
|       .entries { | ||||
|         padding: 0; | ||||
|         --md-item-overflow: visible; | ||||
|       } | ||||
|  | ||||
|       .horizontal-line-wrapper { | ||||
|         position: relative; | ||||
|       } | ||||
|       .horizontal-line-wrapper .horizontal-line { | ||||
|         position: absolute; | ||||
|         top: 11px; | ||||
|         margin-inline-start: -28px; | ||||
|         width: 29px; | ||||
|         border-top: 2px dashed var(--divider-color); | ||||
|       } | ||||
|  | ||||
|       button.link { | ||||
|         text-decoration: none; | ||||
|         color: var(--primary-color); | ||||
|       } | ||||
|  | ||||
|       button.link:hover, | ||||
|       button.link:focus { | ||||
|         text-decoration: underline; | ||||
|       } | ||||
|  | ||||
|       .domain { | ||||
|         width: fit-content; | ||||
|         border-radius: var(--ha-border-radius-md); | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-resting); | ||||
|         padding: var(--ha-space-1); | ||||
|         font-family: var(--ha-font-family-code); | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-target-picker-item-row": HaTargetPickerItemRow; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1105
									
								
								src/components/target-picker/ha-target-picker-selector.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1105
									
								
								src/components/target-picker/ha-target-picker-selector.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user