mirror of
				https://github.com/home-assistant/frontend.git
				synced 2025-10-31 06:29:43 +00:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			sec_pypi_p
			...
			triggers-d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 2c87327e34 | ||
|   | 9b9078229a | 
							
								
								
									
										4
									
								
								.github/workflows/cast_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/cast_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -26,7 +26,7 @@ jobs: | |||||||
|           ref: dev |           ref: dev | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -61,7 +61,7 @@ jobs: | |||||||
|           ref: master |           ref: master | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -26,7 +26,7 @@ jobs: | |||||||
|       - name: Check out files from GitHub |       - name: Check out files from GitHub | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -60,7 +60,7 @@ jobs: | |||||||
|       - name: Check out files from GitHub |       - name: Check out files from GitHub | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -78,7 +78,7 @@ jobs: | |||||||
|       - name: Check out files from GitHub |       - name: Check out files from GitHub | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -102,7 +102,7 @@ jobs: | |||||||
|       - name: Check out files from GitHub |       - name: Check out files from GitHub | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -36,14 +36,14 @@ jobs: | |||||||
|  |  | ||||||
|       # Initializes the CodeQL tools for scanning. |       # Initializes the CodeQL tools for scanning. | ||||||
|       - name: Initialize CodeQL |       - name: Initialize CodeQL | ||||||
|         uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 |         uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 | ||||||
|         with: |         with: | ||||||
|           languages: ${{ matrix.language }} |           languages: ${{ matrix.language }} | ||||||
|  |  | ||||||
|       # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). |       # 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) |       # If this step fails, then you should remove it and run the build manually (see below) | ||||||
|       - name: Autobuild |       - name: Autobuild | ||||||
|         uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 |         uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 | ||||||
|  |  | ||||||
|       # ℹ️ Command-line programs to run using the OS shell. |       # ℹ️ Command-line programs to run using the OS shell. | ||||||
|       # 📚 https://git.io/JvXDl |       # 📚 https://git.io/JvXDl | ||||||
| @@ -57,4 +57,4 @@ jobs: | |||||||
|       #   make release |       #   make release | ||||||
|  |  | ||||||
|       - name: Perform CodeQL Analysis |       - name: Perform CodeQL Analysis | ||||||
|         uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 |         uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								.github/workflows/demo_deployment.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/demo_deployment.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -27,7 +27,7 @@ jobs: | |||||||
|           ref: dev |           ref: dev | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -62,7 +62,7 @@ jobs: | |||||||
|           ref: master |           ref: master | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           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 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           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 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -28,7 +28,7 @@ jobs: | |||||||
|           python-version: ${{ env.PYTHON_VERSION }} |           python-version: ${{ env.PYTHON_VERSION }} | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/relative-ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/relative-ci.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - name: Send bundle stats and build information to RelativeCI |       - name: Send bundle stats and build information to RelativeCI | ||||||
|         uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0 |         uses: relative-ci/agent-action@1707825cbfcc7452b2913d273414705415ae64d4 # v3.0.1 | ||||||
|         with: |         with: | ||||||
|           key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} |           key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} | ||||||
|           token: ${{ github.token }} |           token: ${{ github.token }} | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -19,11 +19,8 @@ jobs: | |||||||
|   release: |   release: | ||||||
|     name: Release |     name: Release | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     environment: pypi |  | ||||||
|     permissions: |     permissions: | ||||||
|       contents: write # Required to upload release assets |       contents: write # Required to upload release assets | ||||||
|       id-token: write # For "Trusted Publisher" to PyPi |  | ||||||
|     if: github.repository_owner == 'home-assistant' |  | ||||||
|     steps: |     steps: | ||||||
|       - name: Checkout the repository |       - name: Checkout the repository | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
| @@ -37,7 +34,7 @@ jobs: | |||||||
|         uses: home-assistant/actions/helpers/verify-version@master |         uses: home-assistant/actions/helpers/verify-version@master | ||||||
|  |  | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -49,18 +46,14 @@ jobs: | |||||||
|         run: ./script/translations_download |         run: ./script/translations_download | ||||||
|         env: |         env: | ||||||
|           LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} |           LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} | ||||||
|  |  | ||||||
|       - name: Build and release package |       - name: Build and release package | ||||||
|         run: | |         run: | | ||||||
|           python3 -m pip install build |           python3 -m pip install twine build | ||||||
|  |           export TWINE_USERNAME="__token__" | ||||||
|  |           export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}" | ||||||
|           export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1 |           export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1 | ||||||
|           script/release |           script/release | ||||||
|  |  | ||||||
|       - name: Publish to PyPI |  | ||||||
|         uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 |  | ||||||
|         with: |  | ||||||
|           skip-existing: true |  | ||||||
|  |  | ||||||
|       - name: Upload release assets |       - name: Upload release assets | ||||||
|         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 |         uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 | ||||||
|         with: |         with: | ||||||
| @@ -82,7 +75,7 @@ jobs: | |||||||
|  |  | ||||||
|       # home-assistant/wheels doesn't support SHA pinning |       # home-assistant/wheels doesn't support SHA pinning | ||||||
|       - name: Build wheels |       - name: Build wheels | ||||||
|         uses: home-assistant/wheels@2025.10.0 |         uses: home-assistant/wheels@2025.09.1 | ||||||
|         with: |         with: | ||||||
|           abi: cp313 |           abi: cp313 | ||||||
|           tag: musllinux_1_2 |           tag: musllinux_1_2 | ||||||
| @@ -100,7 +93,7 @@ jobs: | |||||||
|       - name: Checkout the repository |       - name: Checkout the repository | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
| @@ -129,7 +122,7 @@ jobs: | |||||||
|       - name: Checkout the repository |       - name: Checkout the repository | ||||||
|         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | ||||||
|       - name: Setup Node |       - name: Setup Node | ||||||
|         uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 |         uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version-file: ".nvmrc" |           node-version-file: ".nvmrc" | ||||||
|           cache: yarn |           cache: yarn | ||||||
|   | |||||||
							
								
								
									
										32
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								package.json
									
									
									
									
									
								
							| @@ -28,13 +28,13 @@ | |||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@babel/runtime": "7.28.4", |     "@babel/runtime": "7.28.4", | ||||||
|     "@braintree/sanitize-url": "7.1.1", |     "@braintree/sanitize-url": "7.1.1", | ||||||
|     "@codemirror/autocomplete": "6.19.1", |     "@codemirror/autocomplete": "6.19.0", | ||||||
|     "@codemirror/commands": "6.10.0", |     "@codemirror/commands": "6.9.0", | ||||||
|     "@codemirror/language": "6.11.3", |     "@codemirror/language": "6.11.3", | ||||||
|     "@codemirror/legacy-modes": "6.5.2", |     "@codemirror/legacy-modes": "6.5.2", | ||||||
|     "@codemirror/search": "6.5.11", |     "@codemirror/search": "6.5.11", | ||||||
|     "@codemirror/state": "6.5.2", |     "@codemirror/state": "6.5.2", | ||||||
|     "@codemirror/view": "6.38.6", |     "@codemirror/view": "6.38.5", | ||||||
|     "@date-fns/tz": "1.4.1", |     "@date-fns/tz": "1.4.1", | ||||||
|     "@egjs/hammerjs": "2.0.17", |     "@egjs/hammerjs": "2.0.17", | ||||||
|     "@formatjs/intl-datetimeformat": "6.18.2", |     "@formatjs/intl-datetimeformat": "6.18.2", | ||||||
| @@ -52,8 +52,8 @@ | |||||||
|     "@fullcalendar/list": "6.1.19", |     "@fullcalendar/list": "6.1.19", | ||||||
|     "@fullcalendar/luxon3": "6.1.19", |     "@fullcalendar/luxon3": "6.1.19", | ||||||
|     "@fullcalendar/timegrid": "6.1.19", |     "@fullcalendar/timegrid": "6.1.19", | ||||||
|     "@home-assistant/webawesome": "3.0.0-beta.6.ha.6", |     "@home-assistant/webawesome": "3.0.0-beta.6.ha.4", | ||||||
|     "@lezer/highlight": "1.2.2", |     "@lezer/highlight": "1.2.1", | ||||||
|     "@lit-labs/motion": "1.0.9", |     "@lit-labs/motion": "1.0.9", | ||||||
|     "@lit-labs/observers": "2.0.6", |     "@lit-labs/observers": "2.0.6", | ||||||
|     "@lit-labs/virtualizer": "2.1.1", |     "@lit-labs/virtualizer": "2.1.1", | ||||||
| @@ -122,7 +122,7 @@ | |||||||
|     "lit": "3.3.1", |     "lit": "3.3.1", | ||||||
|     "lit-html": "3.3.1", |     "lit-html": "3.3.1", | ||||||
|     "luxon": "3.7.2", |     "luxon": "3.7.2", | ||||||
|     "marked": "16.4.1", |     "marked": "16.4.0", | ||||||
|     "memoize-one": "6.0.0", |     "memoize-one": "6.0.0", | ||||||
|     "node-vibrant": "4.0.3", |     "node-vibrant": "4.0.3", | ||||||
|     "object-hash": "3.0.0", |     "object-hash": "3.0.0", | ||||||
| @@ -153,11 +153,11 @@ | |||||||
|     "@babel/plugin-transform-runtime": "7.28.3", |     "@babel/plugin-transform-runtime": "7.28.3", | ||||||
|     "@babel/preset-env": "7.28.3", |     "@babel/preset-env": "7.28.3", | ||||||
|     "@bundle-stats/plugin-webpack-filter": "4.21.5", |     "@bundle-stats/plugin-webpack-filter": "4.21.5", | ||||||
|     "@lokalise/node-api": "15.3.1", |     "@lokalise/node-api": "15.3.0", | ||||||
|     "@octokit/auth-oauth-device": "8.0.2", |     "@octokit/auth-oauth-device": "8.0.2", | ||||||
|     "@octokit/plugin-retry": "8.0.2", |     "@octokit/plugin-retry": "8.0.2", | ||||||
|     "@octokit/rest": "22.0.0", |     "@octokit/rest": "22.0.0", | ||||||
|     "@rsdoctor/rspack-plugin": "1.3.4", |     "@rsdoctor/rspack-plugin": "1.3.1", | ||||||
|     "@rspack/core": "1.5.8", |     "@rspack/core": "1.5.8", | ||||||
|     "@rspack/dev-server": "1.1.4", |     "@rspack/dev-server": "1.1.4", | ||||||
|     "@types/babel__plugin-transform-runtime": "7.9.5", |     "@types/babel__plugin-transform-runtime": "7.9.5", | ||||||
| @@ -167,7 +167,7 @@ | |||||||
|     "@types/culori": "4.0.1", |     "@types/culori": "4.0.1", | ||||||
|     "@types/html-minifier-terser": "7.0.2", |     "@types/html-minifier-terser": "7.0.2", | ||||||
|     "@types/js-yaml": "4.0.9", |     "@types/js-yaml": "4.0.9", | ||||||
|     "@types/leaflet": "1.9.21", |     "@types/leaflet": "1.9.20", | ||||||
|     "@types/leaflet-draw": "1.0.13", |     "@types/leaflet-draw": "1.0.13", | ||||||
|     "@types/leaflet.markercluster": "1.5.6", |     "@types/leaflet.markercluster": "1.5.6", | ||||||
|     "@types/lodash.merge": "4.6.9", |     "@types/lodash.merge": "4.6.9", | ||||||
| @@ -178,19 +178,19 @@ | |||||||
|     "@types/tar": "6.1.13", |     "@types/tar": "6.1.13", | ||||||
|     "@types/ua-parser-js": "0.7.39", |     "@types/ua-parser-js": "0.7.39", | ||||||
|     "@types/webspeechapi": "0.0.29", |     "@types/webspeechapi": "0.0.29", | ||||||
|     "@vitest/coverage-v8": "4.0.1", |     "@vitest/coverage-v8": "3.2.4", | ||||||
|     "babel-loader": "10.0.0", |     "babel-loader": "10.0.0", | ||||||
|     "babel-plugin-template-html-minifier": "4.1.0", |     "babel-plugin-template-html-minifier": "4.1.0", | ||||||
|     "browserslist-useragent-regexp": "4.1.3", |     "browserslist-useragent-regexp": "4.1.3", | ||||||
|     "del": "8.0.1", |     "del": "8.0.1", | ||||||
|     "eslint": "9.38.0", |     "eslint": "9.37.0", | ||||||
|     "eslint-config-airbnb-base": "15.0.0", |     "eslint-config-airbnb-base": "15.0.0", | ||||||
|     "eslint-config-prettier": "10.1.8", |     "eslint-config-prettier": "10.1.8", | ||||||
|     "eslint-import-resolver-webpack": "0.13.10", |     "eslint-import-resolver-webpack": "0.13.10", | ||||||
|     "eslint-plugin-import": "2.32.0", |     "eslint-plugin-import": "2.32.0", | ||||||
|     "eslint-plugin-lit": "2.1.1", |     "eslint-plugin-lit": "2.1.1", | ||||||
|     "eslint-plugin-lit-a11y": "5.1.1", |     "eslint-plugin-lit-a11y": "5.1.1", | ||||||
|     "eslint-plugin-unused-imports": "4.3.0", |     "eslint-plugin-unused-imports": "4.2.0", | ||||||
|     "eslint-plugin-wc": "3.0.2", |     "eslint-plugin-wc": "3.0.2", | ||||||
|     "fancy-log": "2.0.0", |     "fancy-log": "2.0.0", | ||||||
|     "fs-extra": "11.3.2", |     "fs-extra": "11.3.2", | ||||||
| @@ -201,9 +201,9 @@ | |||||||
|     "gulp-rename": "2.1.0", |     "gulp-rename": "2.1.0", | ||||||
|     "html-minifier-terser": "7.2.0", |     "html-minifier-terser": "7.2.0", | ||||||
|     "husky": "9.1.7", |     "husky": "9.1.7", | ||||||
|     "jsdom": "27.0.1", |     "jsdom": "27.0.0", | ||||||
|     "jszip": "3.10.1", |     "jszip": "3.10.1", | ||||||
|     "lint-staged": "16.2.6", |     "lint-staged": "16.2.3", | ||||||
|     "lit-analyzer": "2.0.3", |     "lit-analyzer": "2.0.3", | ||||||
|     "lodash.merge": "4.6.2", |     "lodash.merge": "4.6.2", | ||||||
|     "lodash.template": "4.5.0", |     "lodash.template": "4.5.0", | ||||||
| @@ -217,9 +217,9 @@ | |||||||
|     "terser-webpack-plugin": "5.3.14", |     "terser-webpack-plugin": "5.3.14", | ||||||
|     "ts-lit-plugin": "2.0.2", |     "ts-lit-plugin": "2.0.2", | ||||||
|     "typescript": "5.9.3", |     "typescript": "5.9.3", | ||||||
|     "typescript-eslint": "8.46.2", |     "typescript-eslint": "8.46.0", | ||||||
|     "vite-tsconfig-paths": "5.1.4", |     "vite-tsconfig-paths": "5.1.4", | ||||||
|     "vitest": "4.0.1", |     "vitest": "3.2.4", | ||||||
|     "webpack-stats-plugin": "1.1.3", |     "webpack-stats-plugin": "1.1.3", | ||||||
|     "webpackbar": "7.0.0", |     "webpackbar": "7.0.0", | ||||||
|     "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" |     "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| #!/bin/sh | #!/bin/sh | ||||||
|  | # Pushes a new version to PyPi. | ||||||
|  |  | ||||||
| # Stop on errors | # Stop on errors | ||||||
| set -e | set -e | ||||||
| @@ -11,4 +12,5 @@ yarn install | |||||||
| script/build_frontend | script/build_frontend | ||||||
|  |  | ||||||
| rm -rf dist home_assistant_frontend.egg-info | rm -rf dist home_assistant_frontend.egg-info | ||||||
| python3 -m build -q | python3 -m build | ||||||
|  | python3 -m twine upload dist/*.whl --skip-existing | ||||||
|   | |||||||
| @@ -9,11 +9,6 @@ import { getEntityContext } from "./context/get_entity_context"; | |||||||
|  |  | ||||||
| const DEFAULT_SEPARATOR = " "; | const DEFAULT_SEPARATOR = " "; | ||||||
|  |  | ||||||
| export const DEFAULT_ENTITY_NAME = [ |  | ||||||
|   { type: "device" }, |  | ||||||
|   { type: "entity" }, |  | ||||||
| ] satisfies EntityNameItem[]; |  | ||||||
|  |  | ||||||
| export type EntityNameItem = | export type EntityNameItem = | ||||||
|   | { |   | { | ||||||
|       type: "entity" | "device" | "area" | "floor"; |       type: "entity" | "device" | "area" | "floor"; | ||||||
| @@ -29,14 +24,14 @@ export interface EntityNameOptions { | |||||||
|  |  | ||||||
| export const computeEntityNameDisplay = ( | export const computeEntityNameDisplay = ( | ||||||
|   stateObj: HassEntity, |   stateObj: HassEntity, | ||||||
|   name: EntityNameItem | EntityNameItem[] | undefined, |   name: EntityNameItem | EntityNameItem[], | ||||||
|   entities: HomeAssistant["entities"], |   entities: HomeAssistant["entities"], | ||||||
|   devices: HomeAssistant["devices"], |   devices: HomeAssistant["devices"], | ||||||
|   areas: HomeAssistant["areas"], |   areas: HomeAssistant["areas"], | ||||||
|   floors: HomeAssistant["floors"], |   floors: HomeAssistant["floors"], | ||||||
|   options?: EntityNameOptions |   options?: EntityNameOptions | ||||||
| ) => { | ) => { | ||||||
|   let items = ensureArray(name || DEFAULT_ENTITY_NAME); |   let items = ensureArray(name); | ||||||
|  |  | ||||||
|   const separator = options?.separator ?? DEFAULT_SEPARATOR; |   const separator = options?.separator ?? DEFAULT_SEPARATOR; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,10 +8,10 @@ interface AreaContext { | |||||||
| } | } | ||||||
| export const getAreaContext = ( | export const getAreaContext = ( | ||||||
|   area: AreaRegistryEntry, |   area: AreaRegistryEntry, | ||||||
|   hassFloors: HomeAssistant["floors"] |   hass: HomeAssistant | ||||||
| ): AreaContext => { | ): AreaContext => { | ||||||
|   const floorId = area.floor_id; |   const floorId = area.floor_id; | ||||||
|   const floor = floorId ? hassFloors[floorId] : undefined; |   const floor = floorId ? hass.floors[floorId] : undefined; | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|     area: area, |     area: area, | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; | |||||||
| import "./ha-progress-button"; | import "./ha-progress-button"; | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
| import type { Appearance } from "../ha-button"; |  | ||||||
|  |  | ||||||
| @customElement("ha-call-service-button") | @customElement("ha-call-service-button") | ||||||
| class HaCallServiceButton extends LitElement { | class HaCallServiceButton extends LitElement { | ||||||
| @@ -26,14 +25,12 @@ class HaCallServiceButton extends LitElement { | |||||||
|  |  | ||||||
|   @property() public confirmation?; |   @property() public confirmation?; | ||||||
|  |  | ||||||
|   @property() public appearance: Appearance = "plain"; |  | ||||||
|  |  | ||||||
|   public render(): TemplateResult { |   public render(): TemplateResult { | ||||||
|     return html` |     return html` | ||||||
|       <ha-progress-button |       <ha-progress-button | ||||||
|         .progress=${this.progress} |         .progress=${this.progress} | ||||||
|         .disabled=${this.disabled} |         .disabled=${this.disabled} | ||||||
|         .appearance=${this.appearance} |         appearance="plain" | ||||||
|         @click=${this._buttonTapped} |         @click=${this._buttonTapped} | ||||||
|         tabindex="0" |         tabindex="0" | ||||||
|       > |       > | ||||||
|   | |||||||
| @@ -1,22 +1,21 @@ | |||||||
| import type { LineSeriesOption } from "echarts"; | import type { LineSeriesOption } from "echarts"; | ||||||
|  |  | ||||||
| export function downSampleLineData< | export function downSampleLineData( | ||||||
|   T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number], |   data: LineSeriesOption["data"], | ||||||
| >( |   chartWidth: number, | ||||||
|   data: T[] | undefined, |  | ||||||
|   maxDetails: number, |  | ||||||
|   minX?: number, |   minX?: number, | ||||||
|   maxX?: number |   maxX?: number | ||||||
| ): T[] { | ) { | ||||||
|   if (!data) { |   if (!data || data.length < 10) { | ||||||
|     return []; |     return data; | ||||||
|   } |   } | ||||||
|   if (data.length <= maxDetails) { |   const width = chartWidth * window.devicePixelRatio; | ||||||
|  |   if (data.length <= width) { | ||||||
|     return data; |     return data; | ||||||
|   } |   } | ||||||
|   const min = minX ?? getPointData(data[0]!)[0]; |   const min = minX ?? getPointData(data[0]!)[0]; | ||||||
|   const max = maxX ?? getPointData(data[data.length - 1]!)[0]; |   const max = maxX ?? getPointData(data[data.length - 1]!)[0]; | ||||||
|   const step = Math.ceil((max - min) / Math.floor(maxDetails)); |   const step = Math.floor((max - min) / width); | ||||||
|   const frames = new Map< |   const frames = new Map< | ||||||
|     number, |     number, | ||||||
|     { |     { | ||||||
| @@ -48,7 +47,7 @@ export function downSampleLineData< | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Convert frames back to points |   // Convert frames back to points | ||||||
|   const result: T[] = []; |   const result: typeof data = []; | ||||||
|   for (const [_i, frame] of frames) { |   for (const [_i, frame] of frames) { | ||||||
|     // Use min/max points to preserve visual accuracy |     // Use min/max points to preserve visual accuracy | ||||||
|     // The order of the data must be preserved so max may be before min |     // The order of the data must be preserved so max may be before min | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ import { fireEvent } from "../../common/dom/fire_event"; | |||||||
| import { listenMediaQuery } from "../../common/dom/media_query"; | import { listenMediaQuery } from "../../common/dom/media_query"; | ||||||
| import { themesContext } from "../../data/context"; | import { themesContext } from "../../data/context"; | ||||||
| import type { Themes } from "../../data/ws-themes"; | import type { Themes } from "../../data/ws-themes"; | ||||||
| import type { ECOption } from "../../resources/echarts/echarts"; | import type { ECOption } from "../../resources/echarts"; | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import { isMac } from "../../util/is_mac"; | import { isMac } from "../../util/is_mac"; | ||||||
| import "../chips/ha-assist-chip"; | import "../chips/ha-assist-chip"; | ||||||
| @@ -88,19 +88,9 @@ export class HaChartBase extends LitElement { | |||||||
|  |  | ||||||
|   private _lastTapTime?: number; |   private _lastTapTime?: number; | ||||||
|  |  | ||||||
|   private _shouldResizeChart = false; |  | ||||||
|  |  | ||||||
|   // @ts-ignore |   // @ts-ignore | ||||||
|   private _resizeController = new ResizeController(this, { |   private _resizeController = new ResizeController(this, { | ||||||
|     callback: () => { |     callback: () => this.chart?.resize(), | ||||||
|       if (this.chart) { |  | ||||||
|         if (!this.chart.getZr().animation.isFinished()) { |  | ||||||
|           this._shouldResizeChart = true; |  | ||||||
|         } else { |  | ||||||
|           this.chart.resize(); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   private _loading = false; |   private _loading = false; | ||||||
| @@ -356,7 +346,7 @@ export class HaChartBase extends LitElement { | |||||||
|       if (this.chart) { |       if (this.chart) { | ||||||
|         this.chart.dispose(); |         this.chart.dispose(); | ||||||
|       } |       } | ||||||
|       const echarts = (await import("../../resources/echarts/echarts")).default; |       const echarts = (await import("../../resources/echarts")).default; | ||||||
|  |  | ||||||
|       if (this.extraComponents?.length) { |       if (this.extraComponents?.length) { | ||||||
|         echarts.use(this.extraComponents); |         echarts.use(this.extraComponents); | ||||||
| @@ -376,7 +366,6 @@ export class HaChartBase extends LitElement { | |||||||
|       if (!this.options?.dataZoom) { |       if (!this.options?.dataZoom) { | ||||||
|         this.chart.getZr().on("dblclick", this._handleClickZoom); |         this.chart.getZr().on("dblclick", this._handleClickZoom); | ||||||
|       } |       } | ||||||
|       this.chart.on("finished", this._handleChartRenderFinished); |  | ||||||
|       if (this._isTouchDevice) { |       if (this._isTouchDevice) { | ||||||
|         this.chart.getZr().on("click", (e: ECElementEvent) => { |         this.chart.getZr().on("click", (e: ECElementEvent) => { | ||||||
|           if (!e.zrByTouch) { |           if (!e.zrByTouch) { | ||||||
| @@ -816,7 +805,7 @@ export class HaChartBase extends LitElement { | |||||||
|             sampling: undefined, |             sampling: undefined, | ||||||
|             data: downSampleLineData( |             data: downSampleLineData( | ||||||
|               data as LineSeriesOption["data"], |               data as LineSeriesOption["data"], | ||||||
|               this.clientWidth * window.devicePixelRatio, |               this.clientWidth, | ||||||
|               minX, |               minX, | ||||||
|               maxX |               maxX | ||||||
|             ), |             ), | ||||||
| @@ -956,13 +945,6 @@ export class HaChartBase extends LitElement { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _handleChartRenderFinished = () => { |  | ||||||
|     if (this._shouldResizeChart) { |  | ||||||
|       this.chart?.resize(); |  | ||||||
|       this._shouldResizeChart = false; |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   static styles = css` |   static styles = css` | ||||||
|     :host { |     :host { | ||||||
|       display: block; |       display: block; | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; | |||||||
| import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; | import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { listenMediaQuery } from "../../common/dom/media_query"; | import { listenMediaQuery } from "../../common/dom/media_query"; | ||||||
| import type { ECOption } from "../../resources/echarts/echarts"; | import type { ECOption } from "../../resources/echarts"; | ||||||
| import "./ha-chart-base"; | import "./ha-chart-base"; | ||||||
| import type { HaChartBase } from "./ha-chart-base"; | import type { HaChartBase } from "./ha-chart-base"; | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
|   | |||||||
| @@ -1,13 +1,13 @@ | |||||||
| import { customElement, property, state } from "lit/decorators"; | import { customElement, property, state } from "lit/decorators"; | ||||||
| import { LitElement, html, css } from "lit"; | import { LitElement, html, css } from "lit"; | ||||||
| import type { EChartsType } from "echarts/core"; | import type { EChartsType } from "echarts/core"; | ||||||
|  | import type { CallbackDataParams } from "echarts/types/dist/shared"; | ||||||
| import type { SankeySeriesOption } from "echarts/types/dist/echarts"; | import type { SankeySeriesOption } from "echarts/types/dist/echarts"; | ||||||
| import type { CallbackDataParams } from "echarts/types/src/util/types"; | import { SankeyChart } from "echarts/charts"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { ResizeController } from "@lit-labs/observers/resize-controller"; | import { ResizeController } from "@lit-labs/observers/resize-controller"; | ||||||
| import SankeyChart from "../../resources/echarts/components/sankey/install"; |  | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import type { ECOption } from "../../resources/echarts/echarts"; | import type { ECOption } from "../../resources/echarts"; | ||||||
| import { measureTextWidth } from "../../util/text"; | import { measureTextWidth } from "../../util/text"; | ||||||
| import { filterXSS } from "../../common/util/xss"; | import { filterXSS } from "../../common/util/xss"; | ||||||
| import "./ha-chart-base"; | import "./ha-chart-base"; | ||||||
| @@ -39,7 +39,7 @@ type ProcessedLink = Link & { | |||||||
|  |  | ||||||
| const OVERFLOW_MARGIN = 5; | const OVERFLOW_MARGIN = 5; | ||||||
| const FONT_SIZE = 12; | const FONT_SIZE = 12; | ||||||
| const NODE_GAP = 6; | const NODE_GAP = 8; | ||||||
| const LABEL_DISTANCE = 5; | const LABEL_DISTANCE = 5; | ||||||
|  |  | ||||||
| @customElement("ha-sankey-chart") | @customElement("ha-sankey-chart") | ||||||
| @@ -164,7 +164,6 @@ export class HaSankeyChart extends LitElement { | |||||||
|       lineStyle: { |       lineStyle: { | ||||||
|         color: "gradient", |         color: "gradient", | ||||||
|         opacity: 0.4, |         opacity: 0.4, | ||||||
|         curveness: 0.5, |  | ||||||
|       }, |       }, | ||||||
|       layoutIterations: 0, |       layoutIterations: 0, | ||||||
|       label: { |       label: { | ||||||
|   | |||||||
| @@ -11,7 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl"; | |||||||
| import type { LineChartEntity, LineChartState } from "../../data/history"; | import type { LineChartEntity, LineChartState } from "../../data/history"; | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | ||||||
| import type { ECOption } from "../../resources/echarts/echarts"; | import type { ECOption } from "../../resources/echarts"; | ||||||
| import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; | import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; | ||||||
| import { | import { | ||||||
|   getNumberFormatOptions, |   getNumberFormatOptions, | ||||||
|   | |||||||
| @@ -15,8 +15,8 @@ import type { TimelineEntity } from "../../data/history"; | |||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; | ||||||
| import { computeTimelineColor } from "./timeline-color"; | import { computeTimelineColor } from "./timeline-color"; | ||||||
| import type { ECOption } from "../../resources/echarts/echarts"; | import type { ECOption } from "../../resources/echarts"; | ||||||
| import echarts from "../../resources/echarts/echarts"; | import echarts from "../../resources/echarts"; | ||||||
| import { luminosity } from "../../common/color/rgb"; | import { luminosity } from "../../common/color/rgb"; | ||||||
| import { hex2rgb } from "../../common/color/convert-color"; | import { hex2rgb } from "../../common/color/convert-color"; | ||||||
| import { measureTextWidth } from "../../util/text"; | import { measureTextWidth } from "../../util/text"; | ||||||
|   | |||||||
| @@ -29,7 +29,7 @@ import { | |||||||
|   getStatisticMetadata, |   getStatisticMetadata, | ||||||
|   statisticsHaveType, |   statisticsHaveType, | ||||||
| } from "../../data/recorder"; | } from "../../data/recorder"; | ||||||
| import type { ECOption } from "../../resources/echarts/echarts"; | import type { ECOption } from "../../resources/echarts"; | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import type { CustomLegendOption } from "./ha-chart-base"; | import type { CustomLegendOption } from "./ha-chart-base"; | ||||||
| import "./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 { FilterChip } from "@material/web/chips/internal/filter-chip"; | ||||||
| import { styles } from "@material/web/chips/internal/filter-styles"; | import { styles } from "@material/web/chips/internal/filter-styles"; | ||||||
| import { styles as selectableStyles } from "@material/web/chips/internal/selectable-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 sharedStyles } from "@material/web/chips/internal/shared-styles"; | ||||||
| import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-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 { css, html } from "lit"; | ||||||
| import { customElement, property } from "lit/decorators"; | import { customElement, property } from "lit/decorators"; | ||||||
|  |  | ||||||
| @@ -30,7 +30,6 @@ export class HaFilterChip extends FilterChip { | |||||||
|           var(--rgb-primary-text-color), |           var(--rgb-primary-text-color), | ||||||
|           0.15 |           0.15 | ||||||
|         ); |         ); | ||||||
|         border-radius: var(--ha-border-radius-md); |  | ||||||
|       } |       } | ||||||
|     `, |     `, | ||||||
|   ]; |   ]; | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js"; | import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||||
| import type { CSSResultGroup } from "lit"; | import type { CSSResultGroup } from "lit"; | ||||||
| import { LitElement, css, html, nothing } from "lit"; | import { LitElement, css, html, nothing } from "lit"; | ||||||
| import { customElement, property, state } from "lit/decorators"; | import { customElement, property, state } from "lit/decorators"; | ||||||
| @@ -129,7 +129,7 @@ export class DialogDataTableSettings extends LitElement { | |||||||
|                   ${canMove && isVisible |                   ${canMove && isVisible | ||||||
|                     ? html`<ha-svg-icon |                     ? html`<ha-svg-icon | ||||||
|                         class="handle" |                         class="handle" | ||||||
|                         .path=${mdiDragHorizontalVariant} |                         .path=${mdiDrag} | ||||||
|                         slot="graphic" |                         slot="graphic" | ||||||
|                       ></ha-svg-icon>` |                       ></ha-svg-icon>` | ||||||
|                     : nothing} |                     : nothing} | ||||||
|   | |||||||
| @@ -5,18 +5,24 @@ import { customElement, property, query, state } from "lit/decorators"; | |||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
| import { computeAreaName } from "../../common/entity/compute_area_name"; | import { computeAreaName } from "../../common/entity/compute_area_name"; | ||||||
| import { computeDeviceName } from "../../common/entity/compute_device_name"; | import { | ||||||
|  |   computeDeviceName, | ||||||
|  |   computeDeviceNameDisplay, | ||||||
|  | } from "../../common/entity/compute_device_name"; | ||||||
|  | import { computeDomain } from "../../common/entity/compute_domain"; | ||||||
| import { getDeviceContext } from "../../common/entity/context/get_device_context"; | import { getDeviceContext } from "../../common/entity/context/get_device_context"; | ||||||
| import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; | import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; | ||||||
| import { | import { | ||||||
|   getDevices, |   getDeviceEntityDisplayLookup, | ||||||
|   type DevicePickerItem, |   type DeviceEntityDisplayLookup, | ||||||
|   type DeviceRegistryEntry, |   type DeviceRegistryEntry, | ||||||
| } from "../../data/device_registry"; | } from "../../data/device_registry"; | ||||||
|  | import { domainToName } from "../../data/integration"; | ||||||
| import type { HomeAssistant } from "../../types"; | import type { HomeAssistant } from "../../types"; | ||||||
| import { brandsUrl } from "../../util/brands-url"; | import { brandsUrl } from "../../util/brands-url"; | ||||||
| import "../ha-generic-picker"; | import "../ha-generic-picker"; | ||||||
| import type { HaGenericPicker } from "../ha-generic-picker"; | import type { HaGenericPicker } from "../ha-generic-picker"; | ||||||
|  | import type { PickerComboBoxItem } from "../ha-picker-combo-box"; | ||||||
|  |  | ||||||
| export type HaDevicePickerDeviceFilterFunc = ( | export type HaDevicePickerDeviceFilterFunc = ( | ||||||
|   device: DeviceRegistryEntry |   device: DeviceRegistryEntry | ||||||
| @@ -24,6 +30,11 @@ export type HaDevicePickerDeviceFilterFunc = ( | |||||||
|  |  | ||||||
| export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; | export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; | ||||||
|  |  | ||||||
|  | interface DevicePickerItem extends PickerComboBoxItem { | ||||||
|  |   domain?: string; | ||||||
|  |   domain_name?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
| @customElement("ha-device-picker") | @customElement("ha-device-picker") | ||||||
| export class HaDevicePicker extends LitElement { | export class HaDevicePicker extends LitElement { | ||||||
|   @property({ attribute: false }) public hass!: HomeAssistant; |   @property({ attribute: false }) public hass!: HomeAssistant; | ||||||
| @@ -93,8 +104,6 @@ export class HaDevicePicker extends LitElement { | |||||||
|  |  | ||||||
|   @state() private _configEntryLookup: Record<string, ConfigEntry> = {}; |   @state() private _configEntryLookup: Record<string, ConfigEntry> = {}; | ||||||
|  |  | ||||||
|   private _getDevicesMemoized = memoizeOne(getDevices); |  | ||||||
|  |  | ||||||
|   protected firstUpdated(_changedProperties: PropertyValues): void { |   protected firstUpdated(_changedProperties: PropertyValues): void { | ||||||
|     super.firstUpdated(_changedProperties); |     super.firstUpdated(_changedProperties); | ||||||
|     this._loadConfigEntries(); |     this._loadConfigEntries(); | ||||||
| @@ -108,18 +117,162 @@ export class HaDevicePicker extends LitElement { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _getItems = () => |   private _getItems = () => | ||||||
|     this._getDevicesMemoized( |     this._getDevices( | ||||||
|       this.hass, |       this.hass.devices, | ||||||
|  |       this.hass.entities, | ||||||
|       this._configEntryLookup, |       this._configEntryLookup, | ||||||
|       this.includeDomains, |       this.includeDomains, | ||||||
|       this.excludeDomains, |       this.excludeDomains, | ||||||
|       this.includeDeviceClasses, |       this.includeDeviceClasses, | ||||||
|       this.deviceFilter, |       this.deviceFilter, | ||||||
|       this.entityFilter, |       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( |   private _valueRenderer = memoizeOne( | ||||||
|     (configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => { |     (configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => { | ||||||
|       const deviceId = value; |       const deviceId = value; | ||||||
|   | |||||||
| @@ -1,13 +1,13 @@ | |||||||
| import { mdiDragHorizontalVariant } from "@mdi/js"; | import { mdiDrag } from "@mdi/js"; | ||||||
| import { css, html, LitElement, nothing } from "lit"; | import { css, html, LitElement, nothing } from "lit"; | ||||||
| import { customElement, property } from "lit/decorators"; | import { customElement, property } from "lit/decorators"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
| import { isValidEntityId } from "../../common/entity/valid_entity_id"; | import { isValidEntityId } from "../../common/entity/valid_entity_id"; | ||||||
| import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; |  | ||||||
| import type { HomeAssistant, ValueChangedEvent } from "../../types"; | import type { HomeAssistant, ValueChangedEvent } from "../../types"; | ||||||
| import "../ha-sortable"; | import "../ha-sortable"; | ||||||
| import "./ha-entity-picker"; | import "./ha-entity-picker"; | ||||||
|  | import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker"; | ||||||
|  |  | ||||||
| @customElement("ha-entities-picker") | @customElement("ha-entities-picker") | ||||||
| class HaEntitiesPicker extends LitElement { | class HaEntitiesPicker extends LitElement { | ||||||
| @@ -118,7 +118,7 @@ class HaEntitiesPicker extends LitElement { | |||||||
|                   ? html` |                   ? html` | ||||||
|                       <ha-svg-icon |                       <ha-svg-icon | ||||||
|                         class="entity-handle" |                         class="entity-handle" | ||||||
|                         .path=${mdiDragHorizontalVariant} |                         .path=${mdiDrag} | ||||||
|                       ></ha-svg-icon> |                       ></ha-svg-icon> | ||||||
|                     ` |                     ` | ||||||
|                   : nothing} |                   : nothing} | ||||||
| @@ -147,7 +147,6 @@ class HaEntitiesPicker extends LitElement { | |||||||
|           .createDomains=${this.createDomains} |           .createDomains=${this.createDomains} | ||||||
|           .required=${this.required && !currentEntities.length} |           .required=${this.required && !currentEntities.length} | ||||||
|           @value-changed=${this._addEntity} |           @value-changed=${this._addEntity} | ||||||
|           add-button |  | ||||||
|         ></ha-entity-picker> |         ></ha-entity-picker> | ||||||
|       </div> |       </div> | ||||||
|     `; |     `; | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import type { HassEntity } from "home-assistant-js-websocket"; | ||||||
| import type { PropertyValues } from "lit"; | import type { PropertyValues } from "lit"; | ||||||
| import { LitElement, html, nothing } from "lit"; | import { LitElement, html, nothing } from "lit"; | ||||||
| import { customElement, property, query, state } from "lit/decorators"; | import { customElement, property, query, state } from "lit/decorators"; | ||||||
| @@ -7,6 +8,8 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; | |||||||
| import "../ha-combo-box"; | import "../ha-combo-box"; | ||||||
| import type { HaComboBox } from "../ha-combo-box"; | import type { HaComboBox } from "../ha-combo-box"; | ||||||
|  |  | ||||||
|  | export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; | ||||||
|  |  | ||||||
| interface AttributeOption { | interface AttributeOption { | ||||||
|   value: string; |   value: string; | ||||||
|   label: string; |   label: string; | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import "@material/mwc-menu/mwc-menu-surface"; | import "@material/mwc-menu/mwc-menu-surface"; | ||||||
| import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; | import { mdiDrag, mdiPlus } from "@mdi/js"; | ||||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||||
| import type { IFuseOptions } from "fuse.js"; | import type { IFuseOptions } from "fuse.js"; | ||||||
| import Fuse from "fuse.js"; | import Fuse from "fuse.js"; | ||||||
| @@ -20,13 +20,11 @@ import "../chips/ha-chip-set"; | |||||||
| import "../chips/ha-input-chip"; | import "../chips/ha-input-chip"; | ||||||
| import "../ha-combo-box"; | import "../ha-combo-box"; | ||||||
| import type { HaComboBox } from "../ha-combo-box"; | import type { HaComboBox } from "../ha-combo-box"; | ||||||
| import "../ha-input-helper-text"; |  | ||||||
| import "../ha-sortable"; | import "../ha-sortable"; | ||||||
|  |  | ||||||
| interface EntityNameOption { | interface EntityNameOption { | ||||||
|   primary: string; |   primary: string; | ||||||
|   secondary?: string; |   secondary?: string; | ||||||
|   field_label: string; |  | ||||||
|   value: string; |   value: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -43,23 +41,6 @@ const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]); | |||||||
|  |  | ||||||
| const UNIQUE_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") | @customElement("ha-entity-name-picker") | ||||||
| export class HaEntityNamePicker extends LitElement { | export class HaEntityNamePicker extends LitElement { | ||||||
|   @property({ attribute: false }) public hass!: HomeAssistant; |   @property({ attribute: false }) public hass!: HomeAssistant; | ||||||
| @@ -87,8 +68,8 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|  |  | ||||||
|   private _editIndex?: number; |   private _editIndex?: number; | ||||||
|  |  | ||||||
|   private _validTypes = memoizeOne((entityId?: string) => { |   private _validOptions = memoizeOne((entityId?: string) => { | ||||||
|     const options = new Set<string>(["text"]); |     const options = new Set<string>(); | ||||||
|     if (!entityId) { |     if (!entityId) { | ||||||
|       return options; |       return options; | ||||||
|     } |     } | ||||||
| @@ -120,43 +101,33 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|       return []; |       return []; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const types = this._validTypes(entityId); |     const options = this._validOptions(entityId); | ||||||
|  |  | ||||||
|     const items = ( |     const items = ( | ||||||
|       ["entity", "device", "area", "floor"] as const |       ["entity", "device", "area", "floor"] as const | ||||||
|     ).map<EntityNameOption>((name) => { |     ).map<EntityNameOption>((name) => { | ||||||
|       const stateObj = this.hass.states[entityId]; |       const stateObj = this.hass.states[entityId]; | ||||||
|       const isValid = types.has(name); |       const isValid = options.has(name); | ||||||
|       const primary = this.hass.localize( |       const primary = this.hass.localize( | ||||||
|         `ui.components.entity.entity-name-picker.types.${name}` |         `ui.components.entity.entity-name-picker.types.${name}` | ||||||
|       ); |       ); | ||||||
|       const secondary = |       const secondary = | ||||||
|         (stateObj && isValid |         stateObj && isValid | ||||||
|           ? this.hass.formatEntityName(stateObj, { type: name }) |           ? this.hass.formatEntityName(stateObj, { type: name }) | ||||||
|           : this.hass.localize( |           : this.hass.localize( | ||||||
|               `ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys |               `ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys | ||||||
|             )) || "-"; |             ) || "-"; | ||||||
|  |  | ||||||
|       return { |       return { | ||||||
|         primary, |         primary, | ||||||
|         secondary, |         secondary, | ||||||
|         field_label: primary, |         value: name, | ||||||
|         value: formatOptionValue({ type: name }), |  | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     return items; |     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) => { |   private _formatItem = (item: EntityNameItem) => { | ||||||
|     if (item.type === "text") { |     if (item.type === "text") { | ||||||
|       return `"${item.text}"`; |       return `"${item.text}"`; | ||||||
| @@ -170,9 +141,9 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   protected render() { |   protected render() { | ||||||
|     const value = this._items; |     const value = this._value; | ||||||
|     const options = this._getOptions(this.entityId); |     const options = this._getOptions(this.entityId); | ||||||
|     const validTypes = this._validTypes(this.entityId); |     const validOptions = this._validOptions(this.entityId); | ||||||
|  |  | ||||||
|     return html` |     return html` | ||||||
|       ${this.label ? html`<label>${this.label}</label>` : nothing} |       ${this.label ? html`<label>${this.label}</label>` : nothing} | ||||||
| @@ -186,11 +157,12 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|         > |         > | ||||||
|           <ha-chip-set> |           <ha-chip-set> | ||||||
|             ${repeat( |             ${repeat( | ||||||
|               this._items, |               this._value, | ||||||
|               (item) => item, |               (item) => item, | ||||||
|               (item: EntityNameItem, idx) => { |               (item: EntityNameItem, idx) => { | ||||||
|                 const label = this._formatItem(item); |                 const label = this._formatItem(item); | ||||||
|                 const isValid = validTypes.has(item.type); |                 const isValid = | ||||||
|  |                   item.type === "text" || validOptions.has(item.type); | ||||||
|                 return html` |                 return html` | ||||||
|                   <ha-input-chip |                   <ha-input-chip | ||||||
|                     data-idx=${idx} |                     data-idx=${idx} | ||||||
| @@ -201,10 +173,7 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|                     .disabled=${this.disabled} |                     .disabled=${this.disabled} | ||||||
|                     class=${!isValid ? "invalid" : ""} |                     class=${!isValid ? "invalid" : ""} | ||||||
|                   > |                   > | ||||||
|                     <ha-svg-icon |                     <ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon> | ||||||
|                       slot="icon" |  | ||||||
|                       .path=${mdiDragHorizontalVariant} |  | ||||||
|                     ></ha-svg-icon> |  | ||||||
|                     <span>${label}</span> |                     <span>${label}</span> | ||||||
|                   </ha-input-chip> |                   </ha-input-chip> | ||||||
|                 `; |                 `; | ||||||
| @@ -238,13 +207,14 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|             .hass=${this.hass} |             .hass=${this.hass} | ||||||
|             .value=${""} |             .value=${""} | ||||||
|             .autofocus=${this.autofocus} |             .autofocus=${this.autofocus} | ||||||
|             .disabled=${this.disabled} |             .disabled=${this.disabled || !this.entityId} | ||||||
|             .required=${this.required && !value.length} |             .required=${this.required && !value.length} | ||||||
|  |             .helper=${this.helper} | ||||||
|             .items=${options} |             .items=${options} | ||||||
|             allow-custom-value |             allow-custom-value | ||||||
|             item-id-path="value" |             item-id-path="value" | ||||||
|             item-value-path="value" |             item-value-path="value" | ||||||
|             item-label-path="field_label" |             item-label-path="primary" | ||||||
|             .renderer=${rowRenderer} |             .renderer=${rowRenderer} | ||||||
|             @opened-changed=${this._openedChanged} |             @opened-changed=${this._openedChanged} | ||||||
|             @value-changed=${this._comboBoxValueChanged} |             @value-changed=${this._comboBoxValueChanged} | ||||||
| @@ -253,20 +223,9 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|           </ha-combo-box> |           </ha-combo-box> | ||||||
|         </mwc-menu-surface> |         </mwc-menu-surface> | ||||||
|       </div> |       </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) { |   private _onClosed(ev) { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     this._opened = false; |     this._opened = false; | ||||||
| @@ -295,16 +254,13 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|     this._opened = true; |     this._opened = true; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private get _items(): EntityNameItem[] { |   private get _value(): EntityNameItem[] { | ||||||
|     return this._toItems(this.value); |     return this._toItems(this.value); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _toItems = memoizeOne((value?: typeof this.value) => { |   private _toItems = memoizeOne((value?: typeof this.value) => { | ||||||
|     if (typeof value === "string") { |     if (typeof value === "string") { | ||||||
|       if (value === "") { |       return [{ type: "text", text: value } as const]; | ||||||
|         return []; |  | ||||||
|       } |  | ||||||
|       return [{ type: "text", text: value } satisfies EntityNameItem]; |  | ||||||
|     } |     } | ||||||
|     return value ? ensureArray(value) : []; |     return value ? ensureArray(value) : []; | ||||||
|   }); |   }); | ||||||
| @@ -312,7 +268,7 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|   private _toValue = memoizeOne( |   private _toValue = memoizeOne( | ||||||
|     (items: EntityNameItem[]): typeof this.value => { |     (items: EntityNameItem[]): typeof this.value => { | ||||||
|       if (items.length === 0) { |       if (items.length === 0) { | ||||||
|         return ""; |         return []; | ||||||
|       } |       } | ||||||
|       if (items.length === 1) { |       if (items.length === 1) { | ||||||
|         const item = items[0]; |         const item = items[0]; | ||||||
| @@ -328,21 +284,20 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|       const options = this._comboBox.items || []; |       const options = this._comboBox.items || []; | ||||||
|  |  | ||||||
|       const initialItem = |       const initialItem = | ||||||
|         this._editIndex != null ? this._items[this._editIndex] : undefined; |         this._editIndex != null ? this._value[this._editIndex] : undefined; | ||||||
|  |  | ||||||
|       const initialValue = initialItem ? formatOptionValue(initialItem) : ""; |       const initialValue = initialItem | ||||||
|  |         ? initialItem.type === "text" | ||||||
|  |           ? initialItem.text | ||||||
|  |           : initialItem.type | ||||||
|  |         : ""; | ||||||
|  |  | ||||||
|       const filteredItems = this._filterSelectedOptions(options, initialValue); |       const filteredItems = this._filterSelectedOptions(options, initialValue); | ||||||
|  |  | ||||||
|       if (initialItem?.type === "text" && initialItem.text) { |  | ||||||
|         filteredItems.push(this._customNameOption(initialItem.text)); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       this._comboBox.filteredItems = filteredItems; |       this._comboBox.filteredItems = filteredItems; | ||||||
|       this._comboBox.setInputValue(initialValue); |       this._comboBox.setInputValue(initialValue); | ||||||
|     } else { |     } else { | ||||||
|       this._opened = false; |       this._opened = false; | ||||||
|       this._comboBox.setInputValue(""); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -350,16 +305,15 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|     options: EntityNameOption[], |     options: EntityNameOption[], | ||||||
|     current?: string |     current?: string | ||||||
|   ) => { |   ) => { | ||||||
|     const items = this._items; |     const value = this._value; | ||||||
|  |  | ||||||
|     const excludedValues = new Set( |     const types = value.map((item) => item.type) as string[]; | ||||||
|       items |  | ||||||
|         .filter((item) => UNIQUE_TYPES.has(item.type)) |  | ||||||
|         .map((item) => formatOptionValue(item)) |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     const filteredOptions = options.filter( |     const filteredOptions = options.filter( | ||||||
|       (option) => !excludedValues.has(option.value) || option.value === current |       (option) => | ||||||
|  |         !UNIQUE_TYPES.has(option.value) || | ||||||
|  |         !types.includes(option.value) || | ||||||
|  |         option.value === current | ||||||
|     ); |     ); | ||||||
|     return filteredOptions; |     return filteredOptions; | ||||||
|   }; |   }; | ||||||
| @@ -370,14 +324,20 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|     const options = this._comboBox.items || []; |     const options = this._comboBox.items || []; | ||||||
|  |  | ||||||
|     const currentItem = |     const currentItem = | ||||||
|       this._editIndex != null ? this._items[this._editIndex] : undefined; |       this._editIndex != null ? this._value[this._editIndex] : undefined; | ||||||
|  |  | ||||||
|     const currentValue = currentItem ? formatOptionValue(currentItem) : ""; |     const currentValue = currentItem | ||||||
|  |       ? currentItem.type === "text" | ||||||
|  |         ? currentItem.text | ||||||
|  |         : currentItem.type | ||||||
|  |       : ""; | ||||||
|  |  | ||||||
|     let filteredItems = this._filterSelectedOptions(options, currentValue); |     this._comboBox.filteredItems = this._filterSelectedOptions( | ||||||
|  |       options, | ||||||
|  |       currentValue | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     if (!filter) { |     if (!filter) { | ||||||
|       this._comboBox.filteredItems = filteredItems; |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -389,16 +349,16 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|       ignoreDiacritics: true, |       ignoreDiacritics: true, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const fuse = new Fuse(filteredItems, fuseOptions); |     const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions); | ||||||
|     filteredItems = fuse.search(filter).map((result) => result.item); |     const filteredItems = fuse.search(filter).map((result) => result.item); | ||||||
|     filteredItems.push(this._customNameOption(input)); |  | ||||||
|     this._comboBox.filteredItems = filteredItems; |     this._comboBox.filteredItems = filteredItems; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private async _moveItem(ev: CustomEvent) { |   private async _moveItem(ev: CustomEvent) { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     const { oldIndex, newIndex } = ev.detail; |     const { oldIndex, newIndex } = ev.detail; | ||||||
|     const value = this._items; |     const value = this._value; | ||||||
|     const newValue = value.concat(); |     const newValue = value.concat(); | ||||||
|     const element = newValue.splice(oldIndex, 1)[0]; |     const element = newValue.splice(oldIndex, 1)[0]; | ||||||
|     newValue.splice(newIndex, 0, element); |     newValue.splice(newIndex, 0, element); | ||||||
| @@ -409,7 +369,7 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|  |  | ||||||
|   private async _removeItem(ev) { |   private async _removeItem(ev) { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     const value = [...this._items]; |     const value = [...this._value]; | ||||||
|     const idx = parseInt(ev.target.dataset.idx, 10); |     const idx = parseInt(ev.target.dataset.idx, 10); | ||||||
|     value.splice(idx, 1); |     value.splice(idx, 1); | ||||||
|     this._setValue(value); |     this._setValue(value); | ||||||
| @@ -425,9 +385,11 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const item: EntityNameItem = parseOptionValue(value); |     const item: EntityNameItem = KNOWN_TYPES.has(value as any) | ||||||
|  |       ? { type: value as EntityNameType } | ||||||
|  |       : { type: "text", text: value }; | ||||||
|  |  | ||||||
|     const newValue = [...this._items]; |     const newValue = [...this._value]; | ||||||
|  |  | ||||||
|     if (this._editIndex != null) { |     if (this._editIndex != null) { | ||||||
|       newValue[this._editIndex] = item; |       newValue[this._editIndex] = item; | ||||||
| @@ -521,11 +483,6 @@ export class HaEntityNamePicker extends LitElement { | |||||||
|     .sortable-drag { |     .sortable-drag { | ||||||
|       cursor: grabbing; |       cursor: grabbing; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     ha-input-helper-text { |  | ||||||
|       display: block; |  | ||||||
|       margin: var(--ha-space-2) 0 0; |  | ||||||
|     } |  | ||||||
|   `; |   `; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,17 +1,15 @@ | |||||||
| import { mdiPlus, mdiShape } from "@mdi/js"; | import { mdiPlus, mdiShape } from "@mdi/js"; | ||||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | 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 { html, LitElement, nothing, type PropertyValues } from "lit"; | ||||||
| import { customElement, property, query } from "lit/decorators"; | import { customElement, property, query } from "lit/decorators"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
|  | import { computeDomain } from "../../common/entity/compute_domain"; | ||||||
| import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; | import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; | ||||||
|  | import { computeStateName } from "../../common/entity/compute_state_name"; | ||||||
| import { isValidEntityId } from "../../common/entity/valid_entity_id"; | import { isValidEntityId } from "../../common/entity/valid_entity_id"; | ||||||
| import { computeRTL } from "../../common/util/compute_rtl"; | 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 { domainToName } from "../../data/integration"; | ||||||
| import { | import { | ||||||
|   isHelperDomain, |   isHelperDomain, | ||||||
| @@ -22,11 +20,21 @@ import type { HomeAssistant } from "../../types"; | |||||||
| import "../ha-combo-box-item"; | import "../ha-combo-box-item"; | ||||||
| import "../ha-generic-picker"; | import "../ha-generic-picker"; | ||||||
| import type { HaGenericPicker } from "../ha-generic-picker"; | import type { HaGenericPicker } from "../ha-generic-picker"; | ||||||
| import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box"; | import type { | ||||||
|  |   PickerComboBoxItem, | ||||||
|  |   PickerComboBoxSearchFn, | ||||||
|  | } from "../ha-picker-combo-box"; | ||||||
| import type { PickerValueRenderer } from "../ha-picker-field"; | import type { PickerValueRenderer } from "../ha-picker-field"; | ||||||
| import "../ha-svg-icon"; | import "../ha-svg-icon"; | ||||||
| import "./state-badge"; | import "./state-badge"; | ||||||
|  |  | ||||||
|  | interface EntityComboBoxItem extends PickerComboBoxItem { | ||||||
|  |   domain_name?: string; | ||||||
|  |   stateObj?: HassEntity; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; | ||||||
|  |  | ||||||
| const CREATE_ID = "___create-new-entity___"; | const CREATE_ID = "___create-new-entity___"; | ||||||
|  |  | ||||||
| @customElement("ha-entity-picker") | @customElement("ha-entity-picker") | ||||||
| @@ -113,9 +121,6 @@ export class HaEntityPicker extends LitElement { | |||||||
|   @property({ attribute: "hide-clear-icon", type: Boolean }) |   @property({ attribute: "hide-clear-icon", type: Boolean }) | ||||||
|   public hideClearIcon = false; |   public hideClearIcon = false; | ||||||
|  |  | ||||||
|   @property({ attribute: "add-button", type: Boolean }) |  | ||||||
|   public addButton = false; |  | ||||||
|  |  | ||||||
|   @query("ha-generic-picker") private _picker?: HaGenericPicker; |   @query("ha-generic-picker") private _picker?: HaGenericPicker; | ||||||
|  |  | ||||||
|   protected firstUpdated(changedProperties: PropertyValues): void { |   protected firstUpdated(changedProperties: PropertyValues): void { | ||||||
| @@ -250,10 +255,8 @@ export class HaEntityPicker extends LitElement { | |||||||
|     } |     } | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   private _getEntitiesMemoized = memoizeOne(getEntities); |  | ||||||
|  |  | ||||||
|   private _getItems = () => |   private _getItems = () => | ||||||
|     this._getEntitiesMemoized( |     this._getEntities( | ||||||
|       this.hass, |       this.hass, | ||||||
|       this.includeDomains, |       this.includeDomains, | ||||||
|       this.excludeDomains, |       this.excludeDomains, | ||||||
| @@ -261,10 +264,128 @@ export class HaEntityPicker extends LitElement { | |||||||
|       this.includeDeviceClasses, |       this.includeDeviceClasses, | ||||||
|       this.includeUnitOfMeasurement, |       this.includeUnitOfMeasurement, | ||||||
|       this.includeEntities, |       this.includeEntities, | ||||||
|       this.excludeEntities, |       this.excludeEntities | ||||||
|       this.value |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  |   private _getEntities = memoizeOne( | ||||||
|  |     ( | ||||||
|  |       hass: this["hass"], | ||||||
|  |       includeDomains: this["includeDomains"], | ||||||
|  |       excludeDomains: this["excludeDomains"], | ||||||
|  |       entityFilter: this["entityFilter"], | ||||||
|  |       includeDeviceClasses: this["includeDeviceClasses"], | ||||||
|  |       includeUnitOfMeasurement: this["includeUnitOfMeasurement"], | ||||||
|  |       includeEntities: this["includeEntities"], | ||||||
|  |       excludeEntities: this["excludeEntities"] | ||||||
|  |     ): EntityComboBoxItem[] => { | ||||||
|  |       let items: EntityComboBoxItem[] = []; | ||||||
|  |  | ||||||
|  |       let entityIds = Object.keys(hass.states); | ||||||
|  |  | ||||||
|  |       if (includeEntities) { | ||||||
|  |         entityIds = entityIds.filter((entityId) => | ||||||
|  |           includeEntities.includes(entityId) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (excludeEntities) { | ||||||
|  |         entityIds = entityIds.filter( | ||||||
|  |           (entityId) => !excludeEntities.includes(entityId) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (includeDomains) { | ||||||
|  |         entityIds = entityIds.filter((eid) => | ||||||
|  |           includeDomains.includes(computeDomain(eid)) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (excludeDomains) { | ||||||
|  |         entityIds = entityIds.filter( | ||||||
|  |           (eid) => !excludeDomains.includes(computeDomain(eid)) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const isRTL = computeRTL(hass); | ||||||
|  |  | ||||||
|  |       items = entityIds.map<EntityComboBoxItem>((entityId) => { | ||||||
|  |         const stateObj = hass.states[entityId]; | ||||||
|  |  | ||||||
|  |         const friendlyName = computeStateName(stateObj); // Keep this for search | ||||||
|  |  | ||||||
|  |         const [entityName, deviceName, areaName] = computeEntityNameList( | ||||||
|  |           stateObj, | ||||||
|  |           [{ type: "entity" }, { type: "device" }, { type: "area" }], | ||||||
|  |           hass.entities, | ||||||
|  |           hass.devices, | ||||||
|  |           hass.areas, | ||||||
|  |           hass.floors | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         const domainName = domainToName(hass.localize, computeDomain(entityId)); | ||||||
|  |  | ||||||
|  |         const primary = entityName || deviceName || entityId; | ||||||
|  |         const secondary = [areaName, entityName ? deviceName : undefined] | ||||||
|  |           .filter(Boolean) | ||||||
|  |           .join(isRTL ? " ◂ " : " ▸ "); | ||||||
|  |         const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); | ||||||
|  |  | ||||||
|  |         return { | ||||||
|  |           id: entityId, | ||||||
|  |           primary: primary, | ||||||
|  |           secondary: secondary, | ||||||
|  |           domain_name: domainName, | ||||||
|  |           sorting_label: [deviceName, entityName].filter(Boolean).join("_"), | ||||||
|  |           search_labels: [ | ||||||
|  |             entityName, | ||||||
|  |             deviceName, | ||||||
|  |             areaName, | ||||||
|  |             domainName, | ||||||
|  |             friendlyName, | ||||||
|  |             entityId, | ||||||
|  |           ].filter(Boolean) as string[], | ||||||
|  |           a11y_label: a11yLabel, | ||||||
|  |           stateObj: stateObj, | ||||||
|  |         }; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       if (includeDeviceClasses) { | ||||||
|  |         items = items.filter( | ||||||
|  |           (item) => | ||||||
|  |             // We always want to include the entity of the current value | ||||||
|  |             item.id === this.value || | ||||||
|  |             (item.stateObj?.attributes.device_class && | ||||||
|  |               includeDeviceClasses.includes( | ||||||
|  |                 item.stateObj.attributes.device_class | ||||||
|  |               )) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (includeUnitOfMeasurement) { | ||||||
|  |         items = items.filter( | ||||||
|  |           (item) => | ||||||
|  |             // We always want to include the entity of the current value | ||||||
|  |             item.id === this.value || | ||||||
|  |             (item.stateObj?.attributes.unit_of_measurement && | ||||||
|  |               includeUnitOfMeasurement.includes( | ||||||
|  |                 item.stateObj.attributes.unit_of_measurement | ||||||
|  |               )) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (entityFilter) { | ||||||
|  |         items = items.filter( | ||||||
|  |           (item) => | ||||||
|  |             // We always want to include the entity of the current value | ||||||
|  |             item.id === this.value || | ||||||
|  |             (item.stateObj && entityFilter!(item.stateObj)) | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return items; | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   protected render() { |   protected render() { | ||||||
|     const placeholder = |     const placeholder = | ||||||
|       this.placeholder ?? |       this.placeholder ?? | ||||||
| @@ -284,7 +405,7 @@ export class HaEntityPicker extends LitElement { | |||||||
|         .searchLabel=${this.searchLabel} |         .searchLabel=${this.searchLabel} | ||||||
|         .notFoundLabel=${notFoundLabel} |         .notFoundLabel=${notFoundLabel} | ||||||
|         .placeholder=${placeholder} |         .placeholder=${placeholder} | ||||||
|         .value=${this.addButton ? undefined : this.value} |         .value=${this.value} | ||||||
|         .rowRenderer=${this._rowRenderer} |         .rowRenderer=${this._rowRenderer} | ||||||
|         .getItems=${this._getItems} |         .getItems=${this._getItems} | ||||||
|         .getAdditionalItems=${this._getAdditionalItems} |         .getAdditionalItems=${this._getAdditionalItems} | ||||||
| @@ -292,9 +413,6 @@ export class HaEntityPicker extends LitElement { | |||||||
|         .searchFn=${this._searchFn} |         .searchFn=${this._searchFn} | ||||||
|         .valueRenderer=${this._valueRenderer} |         .valueRenderer=${this._valueRenderer} | ||||||
|         @value-changed=${this._valueChanged} |         @value-changed=${this._valueChanged} | ||||||
|         .addButtonLabel=${this.addButton |  | ||||||
|           ? this.hass.localize("ui.components.entity.entity-picker.add") |  | ||||||
|           : undefined} |  | ||||||
|       > |       > | ||||||
|       </ha-generic-picker> |       </ha-generic-picker> | ||||||
|     `; |     `; | ||||||
|   | |||||||
| @@ -1,39 +1,23 @@ | |||||||
| import "@material/mwc-menu/mwc-menu-surface"; | import { mdiDrag } from "@mdi/js"; | ||||||
| import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; |  | ||||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; |  | ||||||
| import type { IFuseOptions } from "fuse.js"; |  | ||||||
| import Fuse from "fuse.js"; |  | ||||||
| import type { HassEntity } from "home-assistant-js-websocket"; | import type { HassEntity } from "home-assistant-js-websocket"; | ||||||
| import { css, html, LitElement, nothing } from "lit"; | import type { PropertyValues } from "lit"; | ||||||
|  | import { LitElement, css, html, nothing } from "lit"; | ||||||
| import { customElement, property, query, state } from "lit/decorators"; | import { customElement, property, query, state } from "lit/decorators"; | ||||||
| import { repeat } from "lit/directives/repeat"; | import { repeat } from "lit/directives/repeat"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { ensureArray } from "../../common/array/ensure-array"; | import { ensureArray } from "../../common/array/ensure-array"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
| import { stopPropagation } from "../../common/dom/stop_propagation"; |  | ||||||
| import { computeDomain } from "../../common/entity/compute_domain"; | import { computeDomain } from "../../common/entity/compute_domain"; | ||||||
| import { | import { | ||||||
|   STATE_DISPLAY_SPECIAL_CONTENT, |   STATE_DISPLAY_SPECIAL_CONTENT, | ||||||
|   STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS, |   STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS, | ||||||
| } from "../../state-display/state-display"; | } from "../../state-display/state-display"; | ||||||
| import type { HomeAssistant, ValueChangedEvent } from "../../types"; | 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 "../ha-combo-box"; | ||||||
| import type { HaComboBox } from "../ha-combo-box"; |  | ||||||
| import "../ha-sortable"; | import "../ha-sortable"; | ||||||
|  | import "../chips/ha-input-chip"; | ||||||
| interface StateContentOption { | import "../chips/ha-chip-set"; | ||||||
|   primary: string; | import type { HaComboBox } from "../ha-combo-box"; | ||||||
|   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 = [ | const HIDDEN_ATTRIBUTES = [ | ||||||
|   "access_token", |   "access_token", | ||||||
| @@ -90,7 +74,7 @@ const HIDDEN_ATTRIBUTES = [ | |||||||
| ]; | ]; | ||||||
|  |  | ||||||
| @customElement("ha-entity-state-content-picker") | @customElement("ha-entity-state-content-picker") | ||||||
| export class HaStateContentPicker extends LitElement { | class HaEntityStatePicker extends LitElement { | ||||||
|   @property({ attribute: false }) public hass!: HomeAssistant; |   @property({ attribute: false }) public hass!: HomeAssistant; | ||||||
|  |  | ||||||
|   @property({ attribute: false }) public entityId?: string; |   @property({ attribute: false }) public entityId?: string; | ||||||
| @@ -111,28 +95,26 @@ export class HaStateContentPicker extends LitElement { | |||||||
|  |  | ||||||
|   @property() public helper?: string; |   @property() public helper?: string; | ||||||
|  |  | ||||||
|   @query(".container", true) private _container?: HTMLDivElement; |   @state() private _opened = false; | ||||||
|  |  | ||||||
|   @query("ha-combo-box", true) private _comboBox!: HaComboBox; |   @query("ha-combo-box", true) private _comboBox!: HaComboBox; | ||||||
|  |  | ||||||
|   @state() private _opened = false; |   protected shouldUpdate(changedProps: PropertyValues) { | ||||||
|  |     return !(!changedProps.has("_opened") && this._opened); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   private _editIndex?: number; |   private options = memoizeOne( | ||||||
|  |  | ||||||
|   private _options = memoizeOne( |  | ||||||
|     (entityId?: string, stateObj?: HassEntity, allowName?: boolean) => { |     (entityId?: string, stateObj?: HassEntity, allowName?: boolean) => { | ||||||
|       const domain = entityId ? computeDomain(entityId) : undefined; |       const domain = entityId ? computeDomain(entityId) : undefined; | ||||||
|       return [ |       return [ | ||||||
|         { |         { | ||||||
|           primary: this.hass.localize( |           label: this.hass.localize("ui.components.state-content-picker.state"), | ||||||
|             "ui.components.state-content-picker.state" |  | ||||||
|           ), |  | ||||||
|           value: "state", |           value: "state", | ||||||
|         }, |         }, | ||||||
|         ...(allowName |         ...(allowName | ||||||
|           ? [ |           ? [ | ||||||
|               { |               { | ||||||
|                 primary: this.hass.localize( |                 label: this.hass.localize( | ||||||
|                   "ui.components.state-content-picker.name" |                   "ui.components.state-content-picker.name" | ||||||
|                 ), |                 ), | ||||||
|                 value: "name", |                 value: "name", | ||||||
| @@ -140,13 +122,13 @@ export class HaStateContentPicker extends LitElement { | |||||||
|             ] |             ] | ||||||
|           : []), |           : []), | ||||||
|         { |         { | ||||||
|           primary: this.hass.localize( |           label: this.hass.localize( | ||||||
|             "ui.components.state-content-picker.last_changed" |             "ui.components.state-content-picker.last_changed" | ||||||
|           ), |           ), | ||||||
|           value: "last_changed", |           value: "last_changed", | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           primary: this.hass.localize( |           label: this.hass.localize( | ||||||
|             "ui.components.state-content-picker.last_updated" |             "ui.components.state-content-picker.last_updated" | ||||||
|           ), |           ), | ||||||
|           value: "last_updated", |           value: "last_updated", | ||||||
| @@ -155,7 +137,7 @@ export class HaStateContentPicker extends LitElement { | |||||||
|           ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) => |           ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) => | ||||||
|               STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content) |               STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content) | ||||||
|             ).map((content) => ({ |             ).map((content) => ({ | ||||||
|               primary: this.hass.localize( |               label: this.hass.localize( | ||||||
|                 `ui.components.state-content-picker.${content}` |                 `ui.components.state-content-picker.${content}` | ||||||
|               ), |               ), | ||||||
|               value: content, |               value: content, | ||||||
| @@ -164,201 +146,105 @@ export class HaStateContentPicker extends LitElement { | |||||||
|         ...Object.keys(stateObj?.attributes ?? {}) |         ...Object.keys(stateObj?.attributes ?? {}) | ||||||
|           .filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) |           .filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) | ||||||
|           .map((attribute) => ({ |           .map((attribute) => ({ | ||||||
|             primary: this.hass.formatEntityAttributeName(stateObj!, attribute), |  | ||||||
|             value: attribute, |             value: attribute, | ||||||
|  |             label: this.hass.formatEntityAttributeName(stateObj!, attribute), | ||||||
|           })), |           })), | ||||||
|       ] satisfies StateContentOption[]; |       ]; | ||||||
|     } |     } | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|  |   private _filter = ""; | ||||||
|  |  | ||||||
|   protected render() { |   protected render() { | ||||||
|  |     if (!this.hass) { | ||||||
|  |       return nothing; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const value = this._value; |     const value = this._value; | ||||||
|  |  | ||||||
|     const stateObj = this.entityId |     const stateObj = this.entityId | ||||||
|       ? this.hass.states[this.entityId] |       ? this.hass.states[this.entityId] | ||||||
|       : undefined; |       : undefined; | ||||||
|  |  | ||||||
|     const options = this._options(this.entityId, stateObj, this.allowName); |     const options = this.options(this.entityId, stateObj, this.allowName); | ||||||
|  |     const optionItems = options.filter( | ||||||
|  |       (option) => !this._value.includes(option.value) | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return html` |     return html` | ||||||
|       ${this.label ? html`<label>${this.label}</label>` : nothing} |       ${value?.length | ||||||
|       <div class="container ${this.disabled ? "disabled" : ""}"> |         ? html` | ||||||
|         <ha-sortable |             <ha-sortable | ||||||
|           no-style |               no-style | ||||||
|           @item-moved=${this._moveItem} |               @item-moved=${this._moveItem} | ||||||
|           .disabled=${this.disabled} |               .disabled=${this.disabled} | ||||||
|           handle-selector="button.primary.action" |               handle-selector="button.primary.action" | ||||||
|           filter=".add" |             > | ||||||
|         > |               <ha-chip-set> | ||||||
|           <ha-chip-set> |                 ${repeat( | ||||||
|             ${repeat( |                   this._value, | ||||||
|               this._value, |                   (item) => item, | ||||||
|               (item) => item, |                   (item, idx) => { | ||||||
|               (item: string, idx) => { |                     const label = | ||||||
|                 const label = options.find((o) => o.value === item)?.primary; |                       options.find((option) => option.value === item)?.label || | ||||||
|                 const isValid = !!label; |                       item; | ||||||
|                 return html` |                     return html` | ||||||
|                   <ha-input-chip |                       <ha-input-chip | ||||||
|                     data-idx=${idx} |                         .idx=${idx} | ||||||
|                     @remove=${this._removeItem} |                         @remove=${this._removeItem} | ||||||
|                     @click=${this._editItem} |                         .label=${label} | ||||||
|                     .label=${label || item} |                         selected | ||||||
|                     .selected=${!this.disabled} |                       > | ||||||
|                     .disabled=${this.disabled} |                         <ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon> | ||||||
|                     class=${!isValid ? "invalid" : ""} |                         ${label} | ||||||
|                   > |                       </ha-input-chip> | ||||||
|                     <ha-svg-icon |                     `; | ||||||
|                       slot="icon" |                   } | ||||||
|                       .path=${mdiDragHorizontalVariant} |                 )} | ||||||
|                     ></ha-svg-icon> |               </ha-chip-set> | ||||||
|                   </ha-input-chip> |             </ha-sortable> | ||||||
|                 `; |           ` | ||||||
|               } |         : nothing} | ||||||
|             )} |  | ||||||
|             ${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> |  | ||||||
|  |  | ||||||
|         <mwc-menu-surface |       <ha-combo-box | ||||||
|           .open=${this._opened} |         item-value-path="value" | ||||||
|           @closed=${this._onClosed} |         item-label-path="label" | ||||||
|           @opened=${this._onOpened} |         .hass=${this.hass} | ||||||
|           @input=${stopPropagation} |         .label=${this.label} | ||||||
|           .anchor=${this._container} |         .helper=${this.helper} | ||||||
|         > |         .disabled=${this.disabled} | ||||||
|           <ha-combo-box |         .required=${this.required && !value.length} | ||||||
|             .hass=${this.hass} |         .value=${""} | ||||||
|             .value=${""} |         .items=${optionItems} | ||||||
|             .autofocus=${this.autofocus} |         allow-custom-value | ||||||
|             .disabled=${this.disabled || !this.entityId} |         @filter-changed=${this._filterChanged} | ||||||
|             .required=${this.required && !value.length} |         @value-changed=${this._comboBoxValueChanged} | ||||||
|             .helper=${this.helper} |         @opened-changed=${this._openedChanged} | ||||||
|             .items=${options} |       ></ha-combo-box> | ||||||
|             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() { |   private get _value() { | ||||||
|     return !this.value ? [] : ensureArray(this.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>) { |   private _openedChanged(ev: ValueChangedEvent<boolean>) { | ||||||
|     const open = ev.detail.value; |     this._opened = ev.detail.value; | ||||||
|     if (open) { |     this._comboBox.filteredItems = this._comboBox.items; | ||||||
|       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 _filterSelectedOptions = ( |   private _filterChanged(ev?: CustomEvent): void { | ||||||
|     options: StateContentOption[], |     this._filter = ev?.detail.value || ""; | ||||||
|     current?: string |  | ||||||
|   ) => { |  | ||||||
|     const value = this._value; |  | ||||||
|  |  | ||||||
|     return options.filter( |     const filteredItems = this._comboBox.items?.filter((item) => { | ||||||
|       (option) => !value.includes(option.value) || option.value === current |       const label = item.label || item.value; | ||||||
|     ); |       return label.toLowerCase().includes(this._filter?.toLowerCase()); | ||||||
|   }; |     }); | ||||||
|  |  | ||||||
|   private _filterChanged(ev: ValueChangedEvent<string>) { |     if (this._filter) { | ||||||
|     const input = ev.detail.value; |       filteredItems?.unshift({ label: this._filter, value: this._filter }); | ||||||
|     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; |     this._comboBox.filteredItems = filteredItems; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -371,40 +257,43 @@ export class HaStateContentPicker extends LitElement { | |||||||
|     newValue.splice(newIndex, 0, element); |     newValue.splice(newIndex, 0, element); | ||||||
|     this._setValue(newValue); |     this._setValue(newValue); | ||||||
|     await this.updateComplete; |     await this.updateComplete; | ||||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); |     this._filterChanged(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private async _removeItem(ev) { |   private async _removeItem(ev) { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     const value = [...this._value]; |     const value: string[] = [...this._value]; | ||||||
|     const idx = parseInt(ev.target.dataset.idx, 10); |     value.splice(ev.target.idx, 1); | ||||||
|     value.splice(idx, 1); |  | ||||||
|     this._setValue(value); |     this._setValue(value); | ||||||
|     await this.updateComplete; |     await this.updateComplete; | ||||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); |     this._filterChanged(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void { |   private _comboBoxValueChanged(ev: CustomEvent): void { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     const value = ev.detail.value; |     const newValue = ev.detail.value; | ||||||
|  |  | ||||||
|     if (this.disabled || value === "") { |     if (this.disabled || newValue === "") { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const newValue = [...this._value]; |     const currentValue = this._value; | ||||||
|  |  | ||||||
|     if (this._editIndex != null) { |     if (currentValue.includes(newValue)) { | ||||||
|       newValue[this._editIndex] = value; |       return; | ||||||
|     } else { |  | ||||||
|       newValue.push(value); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     this._setValue(newValue); |     setTimeout(() => { | ||||||
|  |       this._filterChanged(); | ||||||
|  |       this._comboBox.setInputValue(""); | ||||||
|  |     }, 0); | ||||||
|  |  | ||||||
|  |     this._setValue([...currentValue, newValue]); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _setValue(value: string[]) { |   private _setValue(value: string[]) { | ||||||
|     const newValue = this._toValue(value); |     const newValue = | ||||||
|  |       value.length === 0 ? undefined : value.length === 1 ? value[0] : value; | ||||||
|     this.value = newValue; |     this.value = newValue; | ||||||
|     fireEvent(this, "value-changed", { |     fireEvent(this, "value-changed", { | ||||||
|       value: newValue, |       value: newValue, | ||||||
| @@ -414,64 +303,10 @@ export class HaStateContentPicker extends LitElement { | |||||||
|   static styles = css` |   static styles = css` | ||||||
|     :host { |     :host { | ||||||
|       position: relative; |       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 { |     ha-chip-set { | ||||||
|       padding: var(--ha-space-2) var(--ha-space-2); |       padding: 8px 0; | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .invalid { |  | ||||||
|       text-decoration: line-through; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     .sortable-fallback { |     .sortable-fallback { | ||||||
| @@ -491,6 +326,6 @@ export class HaStateContentPicker extends LitElement { | |||||||
|  |  | ||||||
| declare global { | declare global { | ||||||
|   interface HTMLElementTagNameMap { |   interface HTMLElementTagNameMap { | ||||||
|     "ha-entity-state-content-picker": HaStateContentPicker; |     "ha-entity-state-content-picker": HaEntityStatePicker; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import type { HassEntity } from "home-assistant-js-websocket"; | ||||||
| import type { PropertyValues } from "lit"; | import type { PropertyValues } from "lit"; | ||||||
| import { LitElement, html, nothing } from "lit"; | import { LitElement, html, nothing } from "lit"; | ||||||
| import { customElement, property, query, state } from "lit/decorators"; | import { customElement, property, query, state } from "lit/decorators"; | ||||||
| @@ -8,6 +9,8 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; | |||||||
| import "../ha-combo-box"; | import "../ha-combo-box"; | ||||||
| import type { HaComboBox } from "../ha-combo-box"; | import type { HaComboBox } from "../ha-combo-box"; | ||||||
|  |  | ||||||
|  | export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; | ||||||
|  |  | ||||||
| interface StateOption { | interface StateOption { | ||||||
|   value: string; |   value: string; | ||||||
|   label: string; |   label: string; | ||||||
|   | |||||||
| @@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement { | |||||||
|         </span> |         </span> | ||||||
|         <ha-switch |         <ha-switch | ||||||
|           @change=${this._handleRowClick} |           @change=${this._handleRowClick} | ||||||
|           .checked=${!!baseEnabled} |           .checked=${baseEnabled} | ||||||
|           .preference=${"base"} |           .preference=${"base"} | ||||||
|           .disabled=${loading} |           .disabled=${loading} | ||||||
|           name="base" |           name="base" | ||||||
| @@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement { | |||||||
|               <ha-switch |               <ha-switch | ||||||
|                 .id="switch-${preference}" |                 .id="switch-${preference}" | ||||||
|                 @change=${this._handleRowClick} |                 @change=${this._handleRowClick} | ||||||
|                 .checked=${!!this.analytics?.preferences[preference]} |                 .checked=${this.analytics?.preferences[preference]} | ||||||
|                 .preference=${preference} |                 .preference=${preference} | ||||||
|                 name=${preference} |                 name=${preference} | ||||||
|               > |               > | ||||||
| @@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement { | |||||||
|         </span> |         </span> | ||||||
|         <ha-switch |         <ha-switch | ||||||
|           @change=${this._handleRowClick} |           @change=${this._handleRowClick} | ||||||
|           .checked=${!!this.analytics?.preferences.diagnostics} |           .checked=${this.analytics?.preferences.diagnostics} | ||||||
|           .preference=${"diagnostics"} |           .preference=${"diagnostics"} | ||||||
|           .disabled=${loading} |           .disabled=${loading} | ||||||
|           name="diagnostics" |           name="diagnostics" | ||||||
|   | |||||||
| @@ -8,13 +8,21 @@ import { styleMap } from "lit/directives/style-map"; | |||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { fireEvent } from "../common/dom/fire_event"; | import { fireEvent } from "../common/dom/fire_event"; | ||||||
| import { computeAreaName } from "../common/entity/compute_area_name"; | import { computeAreaName } from "../common/entity/compute_area_name"; | ||||||
|  | import { computeDomain } from "../common/entity/compute_domain"; | ||||||
| import { computeFloorName } from "../common/entity/compute_floor_name"; | import { computeFloorName } from "../common/entity/compute_floor_name"; | ||||||
|  | import { stringCompare } from "../common/string/compare"; | ||||||
| import { computeRTL } from "../common/util/compute_rtl"; | 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 { | import { | ||||||
|   getAreasAndFloors, |   getFloorAreaLookup, | ||||||
|   type AreaFloorValue, |   type FloorRegistryEntry, | ||||||
|   type FloorComboBoxItem, | } from "../data/floor_registry"; | ||||||
| } from "../data/area_floor"; |  | ||||||
| import type { HomeAssistant, ValueChangedEvent } from "../types"; | import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||||
| import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; | import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; | ||||||
| import "./ha-combo-box-item"; | import "./ha-combo-box-item"; | ||||||
| @@ -22,12 +30,24 @@ import "./ha-floor-icon"; | |||||||
| import "./ha-generic-picker"; | import "./ha-generic-picker"; | ||||||
| import type { HaGenericPicker } from "./ha-generic-picker"; | import type { HaGenericPicker } from "./ha-generic-picker"; | ||||||
| import "./ha-icon-button"; | import "./ha-icon-button"; | ||||||
|  | import type { PickerComboBoxItem } from "./ha-picker-combo-box"; | ||||||
| import type { PickerValueRenderer } from "./ha-picker-field"; | import type { PickerValueRenderer } from "./ha-picker-field"; | ||||||
| import "./ha-svg-icon"; | import "./ha-svg-icon"; | ||||||
| import "./ha-tree-indicator"; | import "./ha-tree-indicator"; | ||||||
|  |  | ||||||
| const SEPARATOR = "________"; | 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") | @customElement("ha-area-floor-picker") | ||||||
| export class HaAreaFloorPicker extends LitElement { | export class HaAreaFloorPicker extends LitElement { | ||||||
|   @property({ attribute: false }) public hass!: HomeAssistant; |   @property({ attribute: false }) public hass!: HomeAssistant; | ||||||
| @@ -134,6 +154,243 @@ 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> = ( |   private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = ( | ||||||
|     item, |     item, | ||||||
|     { index }, |     { index }, | ||||||
| @@ -188,16 +445,12 @@ export class HaAreaFloorPicker extends LitElement { | |||||||
|     `; |     `; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); |  | ||||||
|  |  | ||||||
|   private _getItems = () => |   private _getItems = () => | ||||||
|     this._getAreasAndFloorsMemoized( |     this._getAreasAndFloors( | ||||||
|       this.hass.states, |  | ||||||
|       this.hass.floors, |       this.hass.floors, | ||||||
|       this.hass.areas, |       this.hass.areas, | ||||||
|       this.hass.devices, |       this.hass.devices, | ||||||
|       this.hass.entities, |       this.hass.entities, | ||||||
|       this._formatValue, |  | ||||||
|       this.includeDomains, |       this.includeDomains, | ||||||
|       this.excludeDomains, |       this.excludeDomains, | ||||||
|       this.includeDeviceClasses, |       this.includeDeviceClasses, | ||||||
|   | |||||||
| @@ -107,7 +107,7 @@ export class HaAreaPicker extends LitElement { | |||||||
|           `; |           `; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         const { floor } = getAreaContext(area, this.hass.floors); |         const { floor } = getAreaContext(area, this.hass); | ||||||
|  |  | ||||||
|         const areaName = area ? computeAreaName(area) : undefined; |         const areaName = area ? computeAreaName(area) : undefined; | ||||||
|         const floorName = floor ? computeFloorName(floor) : undefined; |         const floorName = floor ? computeFloorName(floor) : undefined; | ||||||
| @@ -279,7 +279,7 @@ export class HaAreaPicker extends LitElement { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       const items = outputAreas.map<PickerComboBoxItem>((area) => { |       const items = outputAreas.map<PickerComboBoxItem>((area) => { | ||||||
|         const { floor } = getAreaContext(area, this.hass.floors); |         const { floor } = getAreaContext(area, this.hass); | ||||||
|         const floorName = floor ? computeFloorName(floor) : undefined; |         const floorName = floor ? computeFloorName(floor) : undefined; | ||||||
|         const areaName = computeAreaName(area); |         const areaName = computeAreaName(area); | ||||||
|         return { |         return { | ||||||
|   | |||||||
| @@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement { | |||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     const items: DisplayItem[] = areas.map((area) => { |     const items: DisplayItem[] = areas.map((area) => { | ||||||
|       const { floor } = getAreaContext(area, this.hass.floors); |       const { floor } = getAreaContext(area, this.hass!); | ||||||
|       return { |       return { | ||||||
|         value: area.area_id, |         value: area.area_id, | ||||||
|         label: area.name, |         label: area.name, | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js"; | import { mdiDrag, mdiTextureBox } from "@mdi/js"; | ||||||
| import type { TemplateResult } from "lit"; | import type { TemplateResult } from "lit"; | ||||||
| import { LitElement, css, html, nothing } from "lit"; | import { LitElement, css, html, nothing } from "lit"; | ||||||
| import { customElement, property } from "lit/decorators"; | import { customElement, property } from "lit/decorators"; | ||||||
| @@ -105,7 +105,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement { | |||||||
|                       <ha-svg-icon |                       <ha-svg-icon | ||||||
|                         class="handle" |                         class="handle" | ||||||
|                         slot="icons" |                         slot="icons" | ||||||
|                         .path=${mdiDragHorizontalVariant} |                         .path=${mdiDrag} | ||||||
|                       ></ha-svg-icon> |                       ></ha-svg-icon> | ||||||
|                     `} |                     `} | ||||||
|                 <ha-items-display-editor |                 <ha-items-display-editor | ||||||
| @@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement { | |||||||
|       ); |       ); | ||||||
|       const groupedItems: Record<string, DisplayItem[]> = areas.reduce( |       const groupedItems: Record<string, DisplayItem[]> = areas.reduce( | ||||||
|         (acc, area) => { |         (acc, area) => { | ||||||
|           const { floor } = getAreaContext(area, this.hass.floors); |           const { floor } = getAreaContext(area, this.hass!); | ||||||
|           const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR; |           const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR; | ||||||
|  |  | ||||||
|           if (!acc[floorId]) { |           if (!acc[floorId]) { | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| import "@home-assistant/webawesome/dist/components/drawer/drawer"; |  | ||||||
| import { css, html, LitElement, type PropertyValues } from "lit"; | import { css, html, LitElement, type PropertyValues } from "lit"; | ||||||
|  | import "@home-assistant/webawesome/dist/components/drawer/drawer"; | ||||||
| import { customElement, property, state } from "lit/decorators"; | import { customElement, property, state } from "lit/decorators"; | ||||||
| import { haStyleScrollbar } from "../resources/styles"; |  | ||||||
|  |  | ||||||
| export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | ||||||
|  |  | ||||||
| @@ -9,9 +8,6 @@ export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | |||||||
| export class HaBottomSheet extends LitElement { | export class HaBottomSheet extends LitElement { | ||||||
|   @property({ type: Boolean }) public open = false; |   @property({ type: Boolean }) public open = false; | ||||||
|  |  | ||||||
|   @property({ type: Boolean, reflect: true, attribute: "flexcontent" }) |  | ||||||
|   public flexContent = false; |  | ||||||
|  |  | ||||||
|   @state() private _drawerOpen = false; |   @state() private _drawerOpen = false; | ||||||
|  |  | ||||||
|   private _handleAfterHide() { |   private _handleAfterHide() { | ||||||
| @@ -38,61 +34,37 @@ export class HaBottomSheet extends LitElement { | |||||||
|         @wa-after-hide=${this._handleAfterHide} |         @wa-after-hide=${this._handleAfterHide} | ||||||
|         without-header |         without-header | ||||||
|       > |       > | ||||||
|         <slot name="header"></slot> |         <slot></slot> | ||||||
|         <div class="body ha-scrollbar"> |  | ||||||
|           <slot></slot> |  | ||||||
|         </div> |  | ||||||
|       </wa-drawer> |       </wa-drawer> | ||||||
|     `; |     `; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static styles = [ |   static styles = css` | ||||||
|     haStyleScrollbar, |     wa-drawer { | ||||||
|     css` |       --wa-color-surface-raised: var( | ||||||
|       wa-drawer { |         --ha-bottom-sheet-surface-background, | ||||||
|         --wa-color-surface-raised: transparent; |         var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), | ||||||
|         --spacing: 0; |       ); | ||||||
|         --size: var(--ha-bottom-sheet-height, auto); |       --spacing: 0; | ||||||
|         --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; |       --size: auto; | ||||||
|         --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; |       --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); |     wa-drawer::part(dialog) { | ||||||
|         align-items: center; |       border-top-left-radius: var( | ||||||
|       } |         --ha-bottom-sheet-border-radius, | ||||||
|       wa-drawer::part(body) { |         var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||||
|         max-width: var(--ha-bottom-sheet-max-width); |       ); | ||||||
|         width: 100%; |       border-top-right-radius: var( | ||||||
|         border-top-left-radius: var( |         --ha-bottom-sheet-border-radius, | ||||||
|           --ha-bottom-sheet-border-radius, |         var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||||
|           var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) |       ); | ||||||
|         ); |       max-height: 90vh; | ||||||
|         border-top-right-radius: var( |       padding-bottom: var(--safe-area-inset-bottom); | ||||||
|           --ha-bottom-sheet-border-radius, |       padding-left: var(--safe-area-inset-left); | ||||||
|           var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) |       padding-right: var(--safe-area-inset-right); | ||||||
|         ); |     } | ||||||
|         background-color: var( |   `; | ||||||
|           --ha-bottom-sheet-surface-background, |  | ||||||
|           var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), |  | ||||||
|         ); |  | ||||||
|         padding: var( |  | ||||||
|           --ha-bottom-sheet-padding, |  | ||||||
|           0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) |  | ||||||
|             var(--safe-area-inset-left) |  | ||||||
|         ); |  | ||||||
|       } |  | ||||||
|       :host([flexcontent]) wa-drawer::part(body) { |  | ||||||
|         display: flex; |  | ||||||
|         flex-direction: column; |  | ||||||
|       } |  | ||||||
|       :host([flexcontent]) .body { |  | ||||||
|         flex: 1; |  | ||||||
|         max-width: 100%; |  | ||||||
|         display: flex; |  | ||||||
|         flex-direction: column; |  | ||||||
|       } |  | ||||||
|     `, |  | ||||||
|   ]; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| declare global { | declare global { | ||||||
|   | |||||||
| @@ -31,9 +31,6 @@ export class HaButtonToggleGroup extends LitElement { | |||||||
|   @property({ type: Boolean, reflect: true, attribute: "no-wrap" }) |   @property({ type: Boolean, reflect: true, attribute: "no-wrap" }) | ||||||
|   public nowrap = false; |   public nowrap = false; | ||||||
|  |  | ||||||
|   @property({ type: Boolean, reflect: true, attribute: "full-width" }) |  | ||||||
|   public fullWidth = false; |  | ||||||
|  |  | ||||||
|   @property() public variant: |   @property() public variant: | ||||||
|     | "brand" |     | "brand" | ||||||
|     | "neutral" |     | "neutral" | ||||||
| @@ -41,13 +38,6 @@ export class HaButtonToggleGroup extends LitElement { | |||||||
|     | "warning" |     | "warning" | ||||||
|     | "danger" = "brand"; |     | "danger" = "brand"; | ||||||
|  |  | ||||||
|   @property({ attribute: "active-variant" }) public activeVariant?: |  | ||||||
|     | "brand" |  | ||||||
|     | "neutral" |  | ||||||
|     | "success" |  | ||||||
|     | "warning" |  | ||||||
|     | "danger"; |  | ||||||
|  |  | ||||||
|   protected render(): TemplateResult { |   protected render(): TemplateResult { | ||||||
|     return html` |     return html` | ||||||
|       <wa-button-group childSelector="ha-button"> |       <wa-button-group childSelector="ha-button"> | ||||||
| @@ -56,9 +46,7 @@ export class HaButtonToggleGroup extends LitElement { | |||||||
|             html`<ha-button |             html`<ha-button | ||||||
|               iconTag="ha-svg-icon" |               iconTag="ha-svg-icon" | ||||||
|               class="icon" |               class="icon" | ||||||
|               .variant=${this.active !== button.value || !this.activeVariant |               .variant=${this.variant} | ||||||
|                 ? this.variant |  | ||||||
|                 : this.activeVariant} |  | ||||||
|               .size=${this.size} |               .size=${this.size} | ||||||
|               .value=${button.value} |               .value=${button.value} | ||||||
|               @click=${this._handleClick} |               @click=${this._handleClick} | ||||||
| @@ -90,19 +78,6 @@ export class HaButtonToggleGroup extends LitElement { | |||||||
|     :host([no-wrap]) wa-button-group::part(base) { |     :host([no-wrap]) wa-button-group::part(base) { | ||||||
|       flex-wrap: nowrap; |       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,8 +86,7 @@ export class HaCameraStream extends LitElement { | |||||||
|     const streams = this._streams( |     const streams = this._streams( | ||||||
|       this._capabilities?.frontend_stream_types, |       this._capabilities?.frontend_stream_types, | ||||||
|       this._hlsStreams, |       this._hlsStreams, | ||||||
|       this._webRtcStreams, |       this._webRtcStreams | ||||||
|       this.muted |  | ||||||
|     ); |     ); | ||||||
|     return html`${repeat( |     return html`${repeat( | ||||||
|       streams, |       streams, | ||||||
| @@ -191,8 +190,7 @@ export class HaCameraStream extends LitElement { | |||||||
|     ( |     ( | ||||||
|       supportedTypes?: StreamType[], |       supportedTypes?: StreamType[], | ||||||
|       hlsStreams?: { hasAudio: boolean; hasVideo: boolean }, |       hlsStreams?: { hasAudio: boolean; hasVideo: boolean }, | ||||||
|       webRtcStreams?: { hasAudio: boolean; hasVideo: boolean }, |       webRtcStreams?: { hasAudio: boolean; hasVideo: boolean } | ||||||
|       muted?: boolean |  | ||||||
|     ): Stream[] => { |     ): Stream[] => { | ||||||
|       if (__DEMO__) { |       if (__DEMO__) { | ||||||
|         return [{ type: MJPEG_STREAM, visible: true }]; |         return [{ type: MJPEG_STREAM, visible: true }]; | ||||||
| @@ -222,10 +220,9 @@ export class HaCameraStream extends LitElement { | |||||||
|         if ( |         if ( | ||||||
|           hlsStreams.hasVideo && |           hlsStreams.hasVideo && | ||||||
|           hlsStreams.hasAudio && |           hlsStreams.hasAudio && | ||||||
|           !webRtcStreams.hasAudio && |           !webRtcStreams.hasAudio | ||||||
|           !muted |  | ||||||
|         ) { |         ) { | ||||||
|           // webRTC stream is missing audio and audio is not muted, use HLS |           // webRTC stream is missing audio, use HLS | ||||||
|           return [{ type: STREAM_TYPE_HLS, visible: true }]; |           return [{ type: STREAM_TYPE_HLS, visible: true }]; | ||||||
|         } |         } | ||||||
|         if (webRtcStreams.hasVideo) { |         if (webRtcStreams.hasVideo) { | ||||||
|   | |||||||
| @@ -239,7 +239,6 @@ export class HaCodeEditor extends ReactiveElement { | |||||||
|       this._loadedCodeMirror.crosshairCursor(), |       this._loadedCodeMirror.crosshairCursor(), | ||||||
|       this._loadedCodeMirror.highlightSelectionMatches(), |       this._loadedCodeMirror.highlightSelectionMatches(), | ||||||
|       this._loadedCodeMirror.highlightActiveLine(), |       this._loadedCodeMirror.highlightActiveLine(), | ||||||
|       this._loadedCodeMirror.dropCursor(), |  | ||||||
|       this._loadedCodeMirror.indentationMarkers({ |       this._loadedCodeMirror.indentationMarkers({ | ||||||
|         thickness: 0, |         thickness: 0, | ||||||
|         activeThickness: 1, |         activeThickness: 1, | ||||||
|   | |||||||
| @@ -6,9 +6,6 @@ export class HaDialogHeader extends LitElement { | |||||||
|   @property({ type: String, attribute: "subtitle-position" }) |   @property({ type: String, attribute: "subtitle-position" }) | ||||||
|   public subtitlePosition: "above" | "below" = "below"; |   public subtitlePosition: "above" | "below" = "below"; | ||||||
|  |  | ||||||
|   @property({ type: Boolean, reflect: true, attribute: "show-border" }) |  | ||||||
|   public showBorder = false; |  | ||||||
|  |  | ||||||
|   protected render() { |   protected render() { | ||||||
|     const titleSlot = html`<div class="header-title"> |     const titleSlot = html`<div class="header-title"> | ||||||
|       <slot name="title"></slot> |       <slot name="title"></slot> | ||||||
| @@ -52,16 +49,12 @@ export class HaDialogHeader extends LitElement { | |||||||
|           display: flex; |           display: flex; | ||||||
|           flex-direction: row; |           flex-direction: row; | ||||||
|           align-items: center; |           align-items: center; | ||||||
|           padding: 0 var(--ha-space-1); |           padding: 4px; | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|         } |         } | ||||||
|         .header-content { |         .header-content { | ||||||
|           flex: 1; |           flex: 1; | ||||||
|           padding: 10px var(--ha-space-1); |           padding: 10px 4px; | ||||||
|           display: flex; |  | ||||||
|           flex-direction: column; |  | ||||||
|           justify-content: center; |  | ||||||
|           min-height: var(--ha-space-12); |  | ||||||
|           min-width: 0; |           min-width: 0; | ||||||
|           overflow: hidden; |           overflow: hidden; | ||||||
|           text-overflow: ellipsis; |           text-overflow: ellipsis; | ||||||
| @@ -70,7 +63,7 @@ export class HaDialogHeader extends LitElement { | |||||||
|         .header-title { |         .header-title { | ||||||
|           height: var( |           height: var( | ||||||
|             --ha-dialog-header-title-height, |             --ha-dialog-header-title-height, | ||||||
|             calc(var(--ha-font-size-xl) + var(--ha-space-1)) |             calc(var(--ha-font-size-xl) + 4px) | ||||||
|           ); |           ); | ||||||
|           font-size: var(--ha-font-size-xl); |           font-size: var(--ha-font-size-xl); | ||||||
|           line-height: var(--ha-line-height-condensed); |           line-height: var(--ha-line-height-condensed); | ||||||
| @@ -83,19 +76,19 @@ export class HaDialogHeader extends LitElement { | |||||||
|         } |         } | ||||||
|         @media all and (min-width: 450px) and (min-height: 500px) { |         @media all and (min-width: 450px) and (min-height: 500px) { | ||||||
|           .header-bar { |           .header-bar { | ||||||
|             padding: 0 var(--ha-space-2); |             padding: 16px; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         .header-navigation-icon { |         .header-navigation-icon { | ||||||
|           flex: none; |           flex: none; | ||||||
|           min-width: var(--ha-space-2); |           min-width: 8px; | ||||||
|           height: 100%; |           height: 100%; | ||||||
|           display: flex; |           display: flex; | ||||||
|           flex-direction: row; |           flex-direction: row; | ||||||
|         } |         } | ||||||
|         .header-action-items { |         .header-action-items { | ||||||
|           flex: none; |           flex: none; | ||||||
|           min-width: var(--ha-space-2); |           min-width: 8px; | ||||||
|           height: 100%; |           height: 100%; | ||||||
|           display: flex; |           display: flex; | ||||||
|           flex-direction: row; |           flex-direction: row; | ||||||
|   | |||||||
| @@ -49,7 +49,6 @@ export class HaExpansionPanel extends LitElement { | |||||||
|           tabindex=${this.noCollapse ? -1 : 0} |           tabindex=${this.noCollapse ? -1 : 0} | ||||||
|           aria-expanded=${this.expanded} |           aria-expanded=${this.expanded} | ||||||
|           aria-controls="sect1" |           aria-controls="sect1" | ||||||
|           part="summary" |  | ||||||
|         > |         > | ||||||
|           ${this.leftChevron ? chevronIcon : nothing} |           ${this.leftChevron ? chevronIcon : nothing} | ||||||
|           <slot name="leading-icon"></slot> |           <slot name="leading-icon"></slot> | ||||||
| @@ -171,11 +170,6 @@ export class HaExpansionPanel extends LitElement { | |||||||
|       margin-left: 8px; |       margin-left: 8px; | ||||||
|       margin-inline-start: 8px; |       margin-inline-start: 8px; | ||||||
|       margin-inline-end: initial; |       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, |     :host([left-chevron]) .summary-icon, | ||||||
|   | |||||||
| @@ -248,7 +248,7 @@ export class HaFilterDevices extends LitElement { | |||||||
|         } |         } | ||||||
|         search-input-outlined { |         search-input-outlined { | ||||||
|           display: block; |           display: block; | ||||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; |           padding: 0 8px; | ||||||
|         } |         } | ||||||
|       `, |       `, | ||||||
|     ]; |     ]; | ||||||
|   | |||||||
| @@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement { | |||||||
|         } |         } | ||||||
|         search-input-outlined { |         search-input-outlined { | ||||||
|           display: block; |           display: block; | ||||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; |           padding: 0 8px; | ||||||
|         } |         } | ||||||
|       `, |       `, | ||||||
|     ]; |     ]; | ||||||
|   | |||||||
| @@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement { | |||||||
|         } |         } | ||||||
|         search-input-outlined { |         search-input-outlined { | ||||||
|           display: block; |           display: block; | ||||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; |           padding: 0 8px; | ||||||
|         } |         } | ||||||
|       `, |       `, | ||||||
|     ]; |     ]; | ||||||
|   | |||||||
| @@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement { | |||||||
|         } |         } | ||||||
|         search-input-outlined { |         search-input-outlined { | ||||||
|           display: block; |           display: block; | ||||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; |           padding: 0 8px; | ||||||
|         } |         } | ||||||
|       `, |       `, | ||||||
|     ]; |     ]; | ||||||
|   | |||||||
| @@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) { | |||||||
|         } |         } | ||||||
|         search-input-outlined { |         search-input-outlined { | ||||||
|           display: block; |           display: block; | ||||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; |           padding: 0 8px; | ||||||
|         } |         } | ||||||
|       `, |       `, | ||||||
|     ]; |     ]; | ||||||
|   | |||||||
| @@ -1,14 +1,10 @@ | |||||||
| import "@home-assistant/webawesome/dist/components/popover/popover"; | import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||||
| import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; | import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; | ||||||
| import { mdiPlaylistPlus } from "@mdi/js"; |  | ||||||
| import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; | import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; | ||||||
| import { customElement, property, query, state } from "lit/decorators"; | import { customElement, property, query, state } from "lit/decorators"; | ||||||
| import { ifDefined } from "lit/directives/if-defined"; | import { ifDefined } from "lit/directives/if-defined"; | ||||||
| import { tinykeys } from "tinykeys"; |  | ||||||
| import { fireEvent } from "../common/dom/fire_event"; | import { fireEvent } from "../common/dom/fire_event"; | ||||||
| import type { HomeAssistant } from "../types"; | import type { HomeAssistant } from "../types"; | ||||||
| import "./ha-bottom-sheet"; |  | ||||||
| import "./ha-button"; |  | ||||||
| import "./ha-combo-box-item"; | import "./ha-combo-box-item"; | ||||||
| import "./ha-icon-button"; | import "./ha-icon-button"; | ||||||
| import "./ha-input-helper-text"; | import "./ha-input-helper-text"; | ||||||
| @@ -19,7 +15,7 @@ import type { | |||||||
|   PickerComboBoxSearchFn, |   PickerComboBoxSearchFn, | ||||||
| } from "./ha-picker-combo-box"; | } from "./ha-picker-combo-box"; | ||||||
| import "./ha-picker-field"; | import "./ha-picker-field"; | ||||||
| import type { PickerValueRenderer } from "./ha-picker-field"; | import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field"; | ||||||
| import "./ha-svg-icon"; | import "./ha-svg-icon"; | ||||||
|  |  | ||||||
| @customElement("ha-generic-picker") | @customElement("ha-generic-picker") | ||||||
| @@ -57,7 +53,7 @@ export class HaGenericPicker extends LitElement { | |||||||
|   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; |   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; | ||||||
|  |  | ||||||
|   @property({ attribute: false }) |   @property({ attribute: false }) | ||||||
|   public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; |   public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>; | ||||||
|  |  | ||||||
|   @property({ attribute: false }) |   @property({ attribute: false }) | ||||||
|   public valueRenderer?: PickerValueRenderer; |   public valueRenderer?: PickerValueRenderer; | ||||||
| @@ -68,130 +64,58 @@ export class HaGenericPicker extends LitElement { | |||||||
|   @property({ attribute: "not-found-label", type: String }) |   @property({ attribute: "not-found-label", type: String }) | ||||||
|   public notFoundLabel?: string; |   public notFoundLabel?: string; | ||||||
|  |  | ||||||
|   /** If set picker shows an add button instead of textbox when value isn't set */ |   @query("ha-picker-field") private _field?: HaPickerField; | ||||||
|   @property({ attribute: "add-button-label" }) public addButtonLabel?: string; |  | ||||||
|  |  | ||||||
|   @query(".container") private _containerElement?: HTMLDivElement; |  | ||||||
|  |  | ||||||
|   @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; |   @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; | ||||||
|  |  | ||||||
|   @state() private _opened = false; |   @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() { |   protected render() { | ||||||
|     return html` |     return html` | ||||||
|       ${this.label |       ${this.label | ||||||
|         ? html`<label ?disabled=${this.disabled}>${this.label}</label>` |         ? html`<label ?disabled=${this.disabled}>${this.label}</label>` | ||||||
|         : nothing} |         : nothing} | ||||||
|       <div class="container"> |       <div class="container"> | ||||||
|         <div id="picker"> |         ${!this._opened | ||||||
|           <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` |           ? html` | ||||||
|               <wa-popover |               <ha-picker-field | ||||||
|                 .open=${this._pickerWrapperOpen} |                 type="button" | ||||||
|                 style="--body-width: ${this._popoverWidth}px;" |                 compact | ||||||
|                 without-arrow |                 aria-label=${ifDefined(this.label)} | ||||||
|                 distance="-4" |                 @click=${this.open} | ||||||
|                 placement="bottom-start" |                 @clear=${this._clear} | ||||||
|                 for="picker" |                 .placeholder=${this.placeholder} | ||||||
|                 auto-size="vertical" |                 .value=${this.value} | ||||||
|                 auto-size-padding="16" |                 .required=${this.required} | ||||||
|                 @wa-after-show=${this._dialogOpened} |                 .disabled=${this.disabled} | ||||||
|                 @wa-after-hide=${this._hidePicker} |                 .hideClearIcon=${this.hideClearIcon} | ||||||
|                 trap-focus |                 .valueRenderer=${this.valueRenderer} | ||||||
|                 role="dialog" |  | ||||||
|                 aria-modal="true" |  | ||||||
|                 aria-label=${this.hass.localize( |  | ||||||
|                   "ui.components.target-picker.add_target" |  | ||||||
|                 )} |  | ||||||
|               > |               > | ||||||
|                 ${this._renderComboBox()} |               </ha-picker-field> | ||||||
|               </wa-popover> |  | ||||||
|             ` |             ` | ||||||
|           : this._pickerWrapperOpen || this._opened |           : html` | ||||||
|             ? html`<ha-bottom-sheet |               <ha-picker-combo-box | ||||||
|                 flexcontent |                 .hass=${this.hass} | ||||||
|                 .open=${this._pickerWrapperOpen} |                 .autofocus=${this.autofocus} | ||||||
|                 @wa-after-show=${this._dialogOpened} |                 .allowCustomValue=${this.allowCustomValue} | ||||||
|                 @closed=${this._hidePicker} |                 .label=${this.searchLabel ?? | ||||||
|                 role="dialog" |                 this.hass.localize("ui.common.search")} | ||||||
|                 aria-modal="true" |                 .value=${this.value} | ||||||
|                 aria-label=${this.hass.localize( |                 hide-clear-icon | ||||||
|                   "ui.components.target-picker.add_target" |                 @opened-changed=${this._openedChanged} | ||||||
|                 )} |                 @value-changed=${this._valueChanged} | ||||||
|               > |                 .rowRenderer=${this.rowRenderer} | ||||||
|                 ${this._renderComboBox(true)} |                 .notFoundLabel=${this.notFoundLabel} | ||||||
|               </ha-bottom-sheet>` |                 .getItems=${this.getItems} | ||||||
|             : nothing} |                 .getAdditionalItems=${this.getAdditionalItems} | ||||||
|  |                 .searchFn=${this.searchFn} | ||||||
|  |               ></ha-picker-combo-box> | ||||||
|  |             `} | ||||||
|       </div> |       </div> | ||||||
|       ${this._renderHelper()} |       ${this._renderHelper()} | ||||||
|     `; |     `; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _renderComboBox(dialogMode = false) { |  | ||||||
|     if (!this._opened) { |  | ||||||
|       return nothing; |  | ||||||
|     } |  | ||||||
|     return html` |  | ||||||
|       <ha-picker-combo-box |  | ||||||
|         .hass=${this.hass} |  | ||||||
|         .allowCustomValue=${this.allowCustomValue} |  | ||||||
|         .label=${this.searchLabel ?? this.hass.localize("ui.common.search")} |  | ||||||
|         .value=${this.value} |  | ||||||
|         @value-changed=${this._valueChanged} |  | ||||||
|         .rowRenderer=${this.rowRenderer} |  | ||||||
|         .notFoundLabel=${this.notFoundLabel} |  | ||||||
|         .getItems=${this.getItems} |  | ||||||
|         .getAdditionalItems=${this.getAdditionalItems} |  | ||||||
|         .searchFn=${this.searchFn} |  | ||||||
|         .mode=${dialogMode ? "dialog" : "popover"} |  | ||||||
|       ></ha-picker-combo-box> |  | ||||||
|     `; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private _renderHelper() { |   private _renderHelper() { | ||||||
|     return this.helper |     return this.helper | ||||||
|       ? html`<ha-input-helper-text .disabled=${this.disabled} |       ? html`<ha-input-helper-text .disabled=${this.disabled} | ||||||
| @@ -200,33 +124,13 @@ export class HaGenericPicker extends LitElement { | |||||||
|       : nothing; |       : 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) { |   private _valueChanged(ev: CustomEvent) { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     const value = ev.detail.value; |     const value = ev.detail.value; | ||||||
|     if (!value) { |     if (!value) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     this._pickerWrapperOpen = false; |     fireEvent(this, "value-changed", { value }); | ||||||
|     this._newValue = value; |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _clear(e) { |   private _clear(e) { | ||||||
| @@ -239,44 +143,24 @@ export class HaGenericPicker extends LitElement { | |||||||
|     fireEvent(this, "value-changed", { value }); |     fireEvent(this, "value-changed", { value }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public async open(ev?: Event) { |   public async open() { | ||||||
|     ev?.stopPropagation(); |  | ||||||
|     if (this.disabled) { |     if (this.disabled) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     this._openedNarrow = this._narrow; |     this._opened = true; | ||||||
|     this._popoverWidth = this._containerElement?.offsetWidth || 250; |     await this.updateComplete; | ||||||
|     this._pickerWrapperOpen = true; |     this._comboBox?.focus(); | ||||||
|     this._unsubscribeTinyKeys = tinykeys(this, { |     this._comboBox?.open(); | ||||||
|       Escape: this._handleEscClose, |  | ||||||
|     }); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   connectedCallback() { |   private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { | ||||||
|     super.connectedCallback(); |     const opened = ev.detail.value; | ||||||
|     this._handleResize(); |     if (this._opened && !opened) { | ||||||
|     window.addEventListener("resize", this._handleResize); |       this._opened = false; | ||||||
|   } |       await this.updateComplete; | ||||||
|  |       this._field?.focus(); | ||||||
|   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 { |   static get styles(): CSSResultGroup { | ||||||
|     return [ |     return [ | ||||||
| @@ -294,45 +178,7 @@ export class HaGenericPicker extends LitElement { | |||||||
|         } |         } | ||||||
|         ha-input-helper-text { |         ha-input-helper-text { | ||||||
|           display: block; |           display: block; | ||||||
|           margin: var(--ha-space-2) 0 0; |           margin: 8px 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 { ResizeController } from "@lit-labs/observers/resize-controller"; | ||||||
| import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js"; | import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js"; | ||||||
| import type { TemplateResult } from "lit"; | import type { TemplateResult } from "lit"; | ||||||
| import { LitElement, css, html, nothing } from "lit"; | import { LitElement, css, html, nothing } from "lit"; | ||||||
| import { customElement, property, state } from "lit/decorators"; | import { customElement, property, state } from "lit/decorators"; | ||||||
| @@ -178,7 +178,7 @@ export class HaItemDisplayEditor extends LitElement { | |||||||
|                             ? this._dragHandleKeydown |                             ? this._dragHandleKeydown | ||||||
|                             : undefined} |                             : undefined} | ||||||
|                           class="handle" |                           class="handle" | ||||||
|                           .path=${mdiDragHorizontalVariant} |                           .path=${mdiDrag} | ||||||
|                           slot="end" |                           slot="end" | ||||||
|                         ></ha-svg-icon> |                         ></ha-svg-icon> | ||||||
|                       ` |                       ` | ||||||
|   | |||||||
| @@ -2,19 +2,19 @@ import { mdiLabel, mdiPlus } from "@mdi/js"; | |||||||
| import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; | import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; | ||||||
| import type { TemplateResult } from "lit"; | import type { TemplateResult } from "lit"; | ||||||
| import { LitElement, html } from "lit"; | import { LitElement, html } from "lit"; | ||||||
| import { | import { customElement, property, query, state } from "lit/decorators"; | ||||||
|   customElement, |  | ||||||
|   property, |  | ||||||
|   query, |  | ||||||
|   queryAssignedElements, |  | ||||||
|   state, |  | ||||||
| } from "lit/decorators"; |  | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { fireEvent } from "../common/dom/fire_event"; | 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 type { LabelRegistryEntry } from "../data/label_registry"; | ||||||
| import { | import { | ||||||
|   createLabelRegistryEntry, |   createLabelRegistryEntry, | ||||||
|   getLabels, |  | ||||||
|   subscribeLabelRegistry, |   subscribeLabelRegistry, | ||||||
| } from "../data/label_registry"; | } from "../data/label_registry"; | ||||||
| import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; | import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; | ||||||
| @@ -90,9 +90,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | |||||||
|  |  | ||||||
|   @state() private _labels?: LabelRegistryEntry[]; |   @state() private _labels?: LabelRegistryEntry[]; | ||||||
|  |  | ||||||
|   @queryAssignedElements({ flatten: true }) |  | ||||||
|   private _slotNodes?: NodeListOf<HTMLElement>; |  | ||||||
|  |  | ||||||
|   @query("ha-generic-picker") private _picker?: HaGenericPicker; |   @query("ha-generic-picker") private _picker?: HaGenericPicker; | ||||||
|  |  | ||||||
|   public async open() { |   public async open() { | ||||||
| @@ -140,22 +137,201 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | |||||||
|       } |       } | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   private _getLabelsMemoized = memoizeOne(getLabels); |   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 _getItems = () => { |       const devices = Object.values(haDevices); | ||||||
|     if (!this._labels || this._labels.length === 0) { |       const entities = Object.values(haEntities); | ||||||
|       return [ |  | ||||||
|         { |       let deviceEntityLookup: DeviceEntityDisplayLookup = {}; | ||||||
|           id: NO_LABELS, |       let inputDevices: DeviceRegistryEntry[] | undefined; | ||||||
|           primary: this.hass.localize("ui.components.label-picker.no_labels"), |       let inputEntities: EntityRegistryDisplayEntry[] | undefined; | ||||||
|           icon_path: mdiLabel, |  | ||||||
|         }, |       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; | ||||||
|     } |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|     return this._getLabelsMemoized( |   private _getItems = () => | ||||||
|       this.hass, |     this._getLabels( | ||||||
|       this._labels, |       this._labels, | ||||||
|  |       this.hass.areas, | ||||||
|  |       this.hass.devices, | ||||||
|  |       this.hass.entities, | ||||||
|       this.includeDomains, |       this.includeDomains, | ||||||
|       this.excludeDomains, |       this.excludeDomains, | ||||||
|       this.includeDeviceClasses, |       this.includeDeviceClasses, | ||||||
| @@ -163,7 +339,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | |||||||
|       this.entityFilter, |       this.entityFilter, | ||||||
|       this.excludeLabels |       this.excludeLabels | ||||||
|     ); |     ); | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { |   private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { | ||||||
|     if (!labels) { |     if (!labels) { | ||||||
| @@ -220,14 +395,12 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | |||||||
|  |  | ||||||
|     return html` |     return html` | ||||||
|       <ha-generic-picker |       <ha-generic-picker | ||||||
|         .disabled=${this.disabled} |  | ||||||
|         .hass=${this.hass} |         .hass=${this.hass} | ||||||
|         .autofocus=${this.autofocus} |         .autofocus=${this.autofocus} | ||||||
|         .label=${this.label} |         .label=${this.label} | ||||||
|         .notFoundLabel=${this.hass.localize( |         .notFoundLabel=${this.hass.localize( | ||||||
|           "ui.components.label-picker.no_match" |           "ui.components.label-picker.no_match" | ||||||
|         )} |         )} | ||||||
|         .addButtonLabel=${this.hass.localize("ui.components.label-picker.add")} |  | ||||||
|         .placeholder=${placeholder} |         .placeholder=${placeholder} | ||||||
|         .value=${this.value} |         .value=${this.value} | ||||||
|         .getItems=${this._getItems} |         .getItems=${this._getItems} | ||||||
| @@ -235,7 +408,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | |||||||
|         .valueRenderer=${valueRenderer} |         .valueRenderer=${valueRenderer} | ||||||
|         @value-changed=${this._valueChanged} |         @value-changed=${this._valueChanged} | ||||||
|       > |       > | ||||||
|         <slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot> |  | ||||||
|       </ha-generic-picker> |       </ha-generic-picker> | ||||||
|     `; |     `; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| import { mdiPlaylistPlus } from "@mdi/js"; |  | ||||||
| import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; | import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; | ||||||
| import type { TemplateResult } from "lit"; | import type { TemplateResult } from "lit"; | ||||||
| import { LitElement, css, html, nothing } from "lit"; | import { LitElement, css, html, nothing } from "lit"; | ||||||
| @@ -124,6 +123,36 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { | |||||||
|     ); |     ); | ||||||
|     return html` |     return html` | ||||||
|       ${this.label ? html`<label>${this.label}</label>` : nothing} |       ${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 |       <ha-label-picker | ||||||
|         .hass=${this.hass} |         .hass=${this.hass} | ||||||
|         .helper=${this.helper} |         .helper=${this.helper} | ||||||
| @@ -133,47 +162,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { | |||||||
|         .excludeLabels=${this.value} |         .excludeLabels=${this.value} | ||||||
|         @value-changed=${this._labelChanged} |         @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> |       </ha-label-picker> | ||||||
|     `; |     `; | ||||||
|   } |   } | ||||||
| @@ -215,25 +203,9 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { | |||||||
|     }, 0); |     }, 0); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _openPicker(ev: Event) { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     this.labelPicker.open(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   static styles = css` |   static styles = css` | ||||||
|     ha-chip-set { |     ha-chip-set { | ||||||
|       margin-bottom: 8px; |       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 { |     ha-input-chip { | ||||||
|       --md-input-chip-selected-container-color: var(--color, var(--grey-color)); |       --md-input-chip-selected-container-color: var(--color, var(--grey-color)); | ||||||
|   | |||||||
| @@ -1,28 +1,19 @@ | |||||||
| import type { LitVirtualizer } from "@lit-labs/virtualizer"; |  | ||||||
| import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; |  | ||||||
| import { mdiMagnify } from "@mdi/js"; | import { mdiMagnify } from "@mdi/js"; | ||||||
|  | import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||||
| import Fuse from "fuse.js"; | import Fuse from "fuse.js"; | ||||||
| import { css, html, LitElement, nothing } from "lit"; | import type { PropertyValues, TemplateResult } from "lit"; | ||||||
| import { | import { html, LitElement, nothing } from "lit"; | ||||||
|   customElement, | import { customElement, property, query, state } from "lit/decorators"; | ||||||
|   eventOptions, |  | ||||||
|   property, |  | ||||||
|   query, |  | ||||||
|   state, |  | ||||||
| } from "lit/decorators"; |  | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { tinykeys } from "tinykeys"; |  | ||||||
| import { fireEvent } from "../common/dom/fire_event"; | import { fireEvent } from "../common/dom/fire_event"; | ||||||
| import { caseInsensitiveStringCompare } from "../common/string/compare"; | import { caseInsensitiveStringCompare } from "../common/string/compare"; | ||||||
| import type { LocalizeFunc } from "../common/translations/localize"; | import type { LocalizeFunc } from "../common/translations/localize"; | ||||||
| import { HaFuse } from "../resources/fuse"; | import { HaFuse } from "../resources/fuse"; | ||||||
| import { haStyleScrollbar } from "../resources/styles"; | import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||||
| import { loadVirtualizer } from "../resources/virtualizer"; | import "./ha-combo-box"; | ||||||
| import type { HomeAssistant } from "../types"; | import type { HaComboBox } from "./ha-combo-box"; | ||||||
| import "./ha-combo-box-item"; | import "./ha-combo-box-item"; | ||||||
| import "./ha-icon"; | import "./ha-icon"; | ||||||
| import "./ha-textfield"; |  | ||||||
| import type { HaTextField } from "./ha-textfield"; |  | ||||||
|  |  | ||||||
| export interface PickerComboBoxItem { | export interface PickerComboBoxItem { | ||||||
|   id: string; |   id: string; | ||||||
| @@ -42,13 +33,10 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem { | |||||||
|  |  | ||||||
| const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___"; | const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___"; | ||||||
|  |  | ||||||
| const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( | const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = ( | ||||||
|   item |   item | ||||||
| ) => html` | ) => html` | ||||||
|   <ha-combo-box-item |   <ha-combo-box-item type="button" compact> | ||||||
|     .type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"} |  | ||||||
|     compact |  | ||||||
|   > |  | ||||||
|     ${item.icon |     ${item.icon | ||||||
|       ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` |       ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` | ||||||
|       : item.icon_path |       : item.icon_path | ||||||
| @@ -85,7 +73,7 @@ export class HaPickerComboBox extends LitElement { | |||||||
|  |  | ||||||
|   @property() public value?: string; |   @property() public value?: string; | ||||||
|  |  | ||||||
|   @state() private _listScrolled = false; |   @property() public helper?: string; | ||||||
|  |  | ||||||
|   @property({ attribute: false, type: Array }) |   @property({ attribute: false, type: Array }) | ||||||
|   public getItems?: () => PickerComboBoxItem[]; |   public getItems?: () => PickerComboBoxItem[]; | ||||||
| @@ -94,7 +82,10 @@ export class HaPickerComboBox extends LitElement { | |||||||
|   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; |   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; | ||||||
|  |  | ||||||
|   @property({ attribute: false }) |   @property({ attribute: false }) | ||||||
|   public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; |   public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>; | ||||||
|  |  | ||||||
|  |   @property({ attribute: "hide-clear-icon", type: Boolean }) | ||||||
|  |   public hideClearIcon = false; | ||||||
|  |  | ||||||
|   @property({ attribute: "not-found-label", type: String }) |   @property({ attribute: "not-found-label", type: String }) | ||||||
|   public notFoundLabel?: string; |   public notFoundLabel?: string; | ||||||
| @@ -102,59 +93,23 @@ export class HaPickerComboBox extends LitElement { | |||||||
|   @property({ attribute: false }) |   @property({ attribute: false }) | ||||||
|   public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; |   public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; | ||||||
|  |  | ||||||
|   @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; |   @state() private _opened = false; | ||||||
|  |  | ||||||
|   @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; |   @query("ha-combo-box", true) public comboBox!: HaComboBox; | ||||||
|  |  | ||||||
|   @query("ha-textfield") private _searchFieldElement?: HaTextField; |   public async open() { | ||||||
|  |     await this.updateComplete; | ||||||
|   @state() private _items: PickerComboBoxItemWithLabel[] = []; |     await this.comboBox?.open(); | ||||||
|  |  | ||||||
|   private _allItems: PickerComboBoxItemWithLabel[] = []; |  | ||||||
|  |  | ||||||
|   private _selectedItemIndex = -1; |  | ||||||
|  |  | ||||||
|   static shadowRootOptions = { |  | ||||||
|     ...LitElement.shadowRootOptions, |  | ||||||
|     delegatesFocus: true, |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _removeKeyboardShortcuts?: () => void; |  | ||||||
|  |  | ||||||
|   protected firstUpdated() { |  | ||||||
|     this._registerKeyboardShortcuts(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public willUpdate() { |   public async focus() { | ||||||
|     if (!this.hasUpdated) { |     await this.updateComplete; | ||||||
|       loadVirtualizer(); |     await this.comboBox?.focus(); | ||||||
|       this._allItems = this._getItems(); |  | ||||||
|       this._items = this._allItems; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   disconnectedCallback() { |   private _initialItems = false; | ||||||
|     super.disconnectedCallback(); |  | ||||||
|     this._removeKeyboardShortcuts?.(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   protected render() { |   private _items: PickerComboBoxItemWithLabel[] = []; | ||||||
|     return html`<ha-textfield |  | ||||||
|         .label=${this.label ?? this.hass.localize("ui.common.search")} |  | ||||||
|         @input=${this._filterChanged} |  | ||||||
|       ></ha-textfield> |  | ||||||
|       <lit-virtualizer |  | ||||||
|         @scroll=${this._onScrollList} |  | ||||||
|         tabindex="0" |  | ||||||
|         scroller |  | ||||||
|         .items=${this._items} |  | ||||||
|         .renderItem=${this._renderItem} |  | ||||||
|         style="min-height: 36px;" |  | ||||||
|         class=${this._listScrolled ? "scrolled" : ""} |  | ||||||
|         @focus=${this._focusList} |  | ||||||
|       > |  | ||||||
|       </lit-virtualizer> `; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private _defaultNotFoundItem = memoizeOne( |   private _defaultNotFoundItem = memoizeOne( | ||||||
|     ( |     ( | ||||||
| @@ -204,56 +159,94 @@ export class HaPickerComboBox extends LitElement { | |||||||
|     return sortedItems; |     return sortedItems; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   private _renderItem = (item: PickerComboBoxItem, index: number) => { |   protected shouldUpdate(changedProps: PropertyValues) { | ||||||
|     const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER; |     if ( | ||||||
|     return html`<div |       changedProps.has("value") || | ||||||
|       id=${`list-item-${index}`} |       changedProps.has("label") || | ||||||
|       class="combo-box-row ${this._value === item.id ? "current-value" : ""}" |       changedProps.has("disabled") | ||||||
|       .value=${item.id} |     ) { | ||||||
|       .index=${index} |       return true; | ||||||
|       @click=${this._valueSelected} |     } | ||||||
|     > |     return !(!changedProps.has("_opened") && this._opened); | ||||||
|       ${item.id === NO_MATCHING_ITEMS_FOUND_ID |   } | ||||||
|         ? DEFAULT_ROW_RENDERER(item, index) |  | ||||||
|         : renderer(item, index)} |  | ||||||
|     </div>`; |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   @eventOptions({ passive: true }) |   public willUpdate(changedProps: PropertyValues) { | ||||||
|   private _onScrollList(ev) { |     if (changedProps.has("_opened") && this._opened) { | ||||||
|     const top = ev.target.scrollTop ?? 0; |       this._items = this._getItems(); | ||||||
|     this._listScrolled = top > 0; |       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> | ||||||
|  |     `; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private get _value() { |   private get _value() { | ||||||
|     return this.value || ""; |     return this.value || ""; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _valueSelected = (ev: Event) => { |   private _openedChanged(ev: ValueChangedEvent<boolean>) { | ||||||
|     ev.stopPropagation(); |     ev.stopPropagation(); | ||||||
|     const value = (ev.currentTarget as any).value as string; |     if (ev.detail.value !== this._opened) { | ||||||
|     const newValue = value?.trim(); |       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(); | ||||||
|  |  | ||||||
|     if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { |     if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fireEvent(this, "value-changed", { value: newValue }); |     if (newValue !== this._value) { | ||||||
|   }; |       this._setValue(newValue); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => |   private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => | ||||||
|     Fuse.createIndex(["search_labels"], states) |     Fuse.createIndex(["search_labels"], states) | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   private _filterChanged = (ev: Event) => { |   private _filterChanged(ev: CustomEvent): void { | ||||||
|     const textfield = ev.target as HaTextField; |     if (!this._opened) return; | ||||||
|     const searchString = textfield.value.trim(); |  | ||||||
|  |  | ||||||
|     const index = this._fuseIndex(this._allItems); |     const target = ev.target as HaComboBox; | ||||||
|     const fuse = new HaFuse(this._allItems, { shouldSort: false }, index); |     const searchString = ev.detail.value.trim() as string; | ||||||
|  |  | ||||||
|  |     const index = this._fuseIndex(this._items); | ||||||
|  |     const fuse = new HaFuse(this._items, { shouldSort: false }, index); | ||||||
|  |  | ||||||
|     const results = fuse.multiTermsSearch(searchString); |     const results = fuse.multiTermsSearch(searchString); | ||||||
|     let filteredItems = this._allItems as PickerComboBoxItem[]; |     let filteredItems = this._items as PickerComboBoxItem[]; | ||||||
|     if (results) { |     if (results) { | ||||||
|       const items = results.map((result) => result.item); |       const items = results.map((result) => result.item); | ||||||
|       if (items.length === 0) { |       if (items.length === 0) { | ||||||
| @@ -267,266 +260,17 @@ export class HaPickerComboBox extends LitElement { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (this.searchFn) { |     if (this.searchFn) { | ||||||
|       filteredItems = this.searchFn( |       filteredItems = this.searchFn(searchString, filteredItems, this._items); | ||||||
|         searchString, |  | ||||||
|         filteredItems, |  | ||||||
|         this._allItems |  | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     this._items = filteredItems as PickerComboBoxItemWithLabel[]; |     target.filteredItems = filteredItems; | ||||||
|     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 _focusList() { |   private _setValue(value: string | undefined) { | ||||||
|     if (this._selectedItemIndex === -1) { |     setTimeout(() => { | ||||||
|       this._selectNextItem(); |       fireEvent(this, "value-changed", { value }); | ||||||
|     } |     }, 0); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _selectNextItem = (ev?: KeyboardEvent) => { |  | ||||||
|     ev?.stopPropagation(); |  | ||||||
|     ev?.preventDefault(); |  | ||||||
|     if (!this._virtualizerElement) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this._searchFieldElement?.focus(); |  | ||||||
|  |  | ||||||
|     const items = this._virtualizerElement.items as PickerComboBoxItem[]; |  | ||||||
|  |  | ||||||
|     const maxItems = items.length - 1; |  | ||||||
|  |  | ||||||
|     if (maxItems === -1) { |  | ||||||
|       this._resetSelectedItem(); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const nextIndex = |  | ||||||
|       maxItems === this._selectedItemIndex |  | ||||||
|         ? this._selectedItemIndex |  | ||||||
|         : this._selectedItemIndex + 1; |  | ||||||
|  |  | ||||||
|     if (!items[nextIndex]) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) { |  | ||||||
|       // Skip titles, padding and empty search |  | ||||||
|       if (nextIndex === maxItems) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       this._selectedItemIndex = nextIndex + 1; |  | ||||||
|     } else { |  | ||||||
|       this._selectedItemIndex = nextIndex; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this._scrollToSelectedItem(); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _selectPreviousItem = (ev: KeyboardEvent) => { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     ev.preventDefault(); |  | ||||||
|     if (!this._virtualizerElement) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this._selectedItemIndex > 0) { |  | ||||||
|       const nextIndex = this._selectedItemIndex - 1; |  | ||||||
|  |  | ||||||
|       const items = this._virtualizerElement.items as PickerComboBoxItem[]; |  | ||||||
|  |  | ||||||
|       if (!items[nextIndex]) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) { |  | ||||||
|         // Skip titles, padding and empty search |  | ||||||
|         if (nextIndex === 0) { |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|         this._selectedItemIndex = nextIndex - 1; |  | ||||||
|       } else { |  | ||||||
|         this._selectedItemIndex = nextIndex; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       this._scrollToSelectedItem(); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _selectFirstItem = (ev: KeyboardEvent) => { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     if (!this._virtualizerElement || !this._virtualizerElement.items.length) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const nextIndex = 0; |  | ||||||
|  |  | ||||||
|     if ( |  | ||||||
|       (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === |  | ||||||
|       NO_MATCHING_ITEMS_FOUND_ID |  | ||||||
|     ) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (typeof this._virtualizerElement.items[nextIndex] === "string") { |  | ||||||
|       this._selectedItemIndex = nextIndex + 1; |  | ||||||
|     } else { |  | ||||||
|       this._selectedItemIndex = nextIndex; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this._scrollToSelectedItem(); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _selectLastItem = (ev: KeyboardEvent) => { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     if (!this._virtualizerElement || !this._virtualizerElement.items.length) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const nextIndex = this._virtualizerElement.items.length - 1; |  | ||||||
|  |  | ||||||
|     if ( |  | ||||||
|       (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === |  | ||||||
|       NO_MATCHING_ITEMS_FOUND_ID |  | ||||||
|     ) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (typeof this._virtualizerElement.items[nextIndex] === "string") { |  | ||||||
|       this._selectedItemIndex = nextIndex - 1; |  | ||||||
|     } else { |  | ||||||
|       this._selectedItemIndex = nextIndex; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     this._scrollToSelectedItem(); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _scrollToSelectedItem = () => { |  | ||||||
|     this._virtualizerElement |  | ||||||
|       ?.querySelector(".selected") |  | ||||||
|       ?.classList.remove("selected"); |  | ||||||
|  |  | ||||||
|     this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end"); |  | ||||||
|  |  | ||||||
|     requestAnimationFrame(() => { |  | ||||||
|       this._virtualizerElement |  | ||||||
|         ?.querySelector(`#list-item-${this._selectedItemIndex}`) |  | ||||||
|         ?.classList.add("selected"); |  | ||||||
|     }); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _pickSelectedItem = (ev: KeyboardEvent) => { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     if (this._selectedItemIndex === -1) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // if filter button is focused |  | ||||||
|     ev.preventDefault(); |  | ||||||
|  |  | ||||||
|     const item: any = this._virtualizerElement?.items[this._selectedItemIndex]; |  | ||||||
|     if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { |  | ||||||
|       fireEvent(this, "value-changed", { value: item.id }); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _resetSelectedItem() { |  | ||||||
|     this._virtualizerElement |  | ||||||
|       ?.querySelector(".selected") |  | ||||||
|       ?.classList.remove("selected"); |  | ||||||
|     this._selectedItemIndex = -1; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   static styles = [ |  | ||||||
|     haStyleScrollbar, |  | ||||||
|     css` |  | ||||||
|       :host { |  | ||||||
|         display: flex; |  | ||||||
|         flex-direction: column; |  | ||||||
|         padding-top: var(--ha-space-3); |  | ||||||
|         flex: 1; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       ha-textfield { |  | ||||||
|         padding: 0 var(--ha-space-3); |  | ||||||
|         margin-bottom: var(--ha-space-3); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       :host([mode="dialog"]) ha-textfield { |  | ||||||
|         padding: 0 var(--ha-space-4); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       ha-combo-box-item { |  | ||||||
|         width: 100%; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       ha-combo-box-item.selected { |  | ||||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       @media (prefers-color-scheme: dark) { |  | ||||||
|         ha-combo-box-item.selected { |  | ||||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       lit-virtualizer { |  | ||||||
|         flex: 1; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       lit-virtualizer:focus-visible { |  | ||||||
|         outline: none; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       lit-virtualizer.scrolled { |  | ||||||
|         border-top: 1px solid var(--ha-color-border-neutral-quiet); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       .bottom-padding { |  | ||||||
|         height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); |  | ||||||
|         width: 100%; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       .empty { |  | ||||||
|         text-align: center; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       .combo-box-row { |  | ||||||
|         display: flex; |  | ||||||
|         width: 100%; |  | ||||||
|         align-items: center; |  | ||||||
|         box-sizing: border-box; |  | ||||||
|         min-height: 36px; |  | ||||||
|       } |  | ||||||
|       .combo-box-row.current-value { |  | ||||||
|         background-color: var(--ha-color-fill-primary-quiet-resting); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       .combo-box-row.selected { |  | ||||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       @media (prefers-color-scheme: dark) { |  | ||||||
|         .combo-box-row.selected { |  | ||||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     `, |  | ||||||
|   ]; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| declare global { | declare global { | ||||||
|   | |||||||
| @@ -137,7 +137,7 @@ export class HaSelect extends SelectBase { | |||||||
|         height: var(--ha-select-height, 56px); |         height: var(--ha-select-height, 56px); | ||||||
|       } |       } | ||||||
|       .mdc-select--filled .mdc-floating-label { |       .mdc-select--filled .mdc-floating-label { | ||||||
|         inset-inline-start: var(--ha-space-4); |         inset-inline-start: 12px; | ||||||
|         inset-inline-end: initial; |         inset-inline-end: initial; | ||||||
|         direction: var(--direction); |         direction: var(--direction); | ||||||
|       } |       } | ||||||
| @@ -147,7 +147,7 @@ export class HaSelect extends SelectBase { | |||||||
|         direction: var(--direction); |         direction: var(--direction); | ||||||
|       } |       } | ||||||
|       .mdc-select .mdc-select__anchor { |       .mdc-select .mdc-select__anchor { | ||||||
|         padding-inline-start: var(--ha-space-4); |         padding-inline-start: 12px; | ||||||
|         padding-inline-end: 0px; |         padding-inline-end: 0px; | ||||||
|         direction: var(--direction); |         direction: var(--direction); | ||||||
|       } |       } | ||||||
| @@ -158,10 +158,7 @@ export class HaSelect extends SelectBase { | |||||||
|         padding-inline-end: var(--select-selected-text-padding-end, 0px); |         padding-inline-end: var(--select-selected-text-padding-end, 0px); | ||||||
|       } |       } | ||||||
|       :host([clearable]) .mdc-select__selected-text-container { |       :host([clearable]) .mdc-select__selected-text-container { | ||||||
|         padding-inline-end: var( |         padding-inline-end: var(--select-selected-text-padding-end, 12px); | ||||||
|           --select-selected-text-padding-end, |  | ||||||
|           var(--ha-space-4) |  | ||||||
|         ); |  | ||||||
|       } |       } | ||||||
|       ha-icon-button { |       ha-icon-button { | ||||||
|         position: absolute; |         position: absolute; | ||||||
|   | |||||||
| @@ -36,8 +36,6 @@ export class HaDeviceSelector extends LitElement { | |||||||
|  |  | ||||||
|   @property() public helper?: string; |   @property() public helper?: string; | ||||||
|  |  | ||||||
|   @property() public placeholder?: string; |  | ||||||
|  |  | ||||||
|   @property({ type: Boolean }) public disabled = false; |   @property({ type: Boolean }) public disabled = false; | ||||||
|  |  | ||||||
|   @property({ type: Boolean }) public required = true; |   @property({ type: Boolean }) public required = true; | ||||||
| @@ -104,7 +102,6 @@ export class HaDeviceSelector extends LitElement { | |||||||
|           .entityFilter=${this.selector.device?.entity |           .entityFilter=${this.selector.device?.entity | ||||||
|             ? this._filterEntities |             ? this._filterEntities | ||||||
|             : undefined} |             : undefined} | ||||||
|           .placeholder=${this.placeholder} |  | ||||||
|           .disabled=${this.disabled} |           .disabled=${this.disabled} | ||||||
|           .required=${this.required} |           .required=${this.required} | ||||||
|           allow-custom-entity |           allow-custom-entity | ||||||
|   | |||||||
| @@ -29,8 +29,6 @@ export class HaEntitySelector extends LitElement { | |||||||
|  |  | ||||||
|   @property() public helper?: string; |   @property() public helper?: string; | ||||||
|  |  | ||||||
|   @property() public placeholder?: any; |  | ||||||
|  |  | ||||||
|   @property({ type: Boolean }) public disabled = false; |   @property({ type: Boolean }) public disabled = false; | ||||||
|  |  | ||||||
|   @property({ type: Boolean }) public required = true; |   @property({ type: Boolean }) public required = true; | ||||||
| @@ -71,7 +69,6 @@ export class HaEntitySelector extends LitElement { | |||||||
|         .excludeEntities=${this.selector.entity?.exclude_entities} |         .excludeEntities=${this.selector.entity?.exclude_entities} | ||||||
|         .entityFilter=${this._filterEntities} |         .entityFilter=${this._filterEntities} | ||||||
|         .createDomains=${this._createDomains} |         .createDomains=${this._createDomains} | ||||||
|         .placeholder=${this.placeholder} |  | ||||||
|         .disabled=${this.disabled} |         .disabled=${this.disabled} | ||||||
|         .required=${this.required} |         .required=${this.required} | ||||||
|         allow-custom-entity |         allow-custom-entity | ||||||
| @@ -89,7 +86,6 @@ export class HaEntitySelector extends LitElement { | |||||||
|         .reorder=${this.selector.entity.reorder ?? false} |         .reorder=${this.selector.entity.reorder ?? false} | ||||||
|         .entityFilter=${this._filterEntities} |         .entityFilter=${this._filterEntities} | ||||||
|         .createDomains=${this._createDomains} |         .createDomains=${this._createDomains} | ||||||
|         .placeholder=${this.placeholder} |  | ||||||
|         .disabled=${this.disabled} |         .disabled=${this.disabled} | ||||||
|         .required=${this.required} |         .required=${this.required} | ||||||
|       ></ha-entities-picker> |       ></ha-entities-picker> | ||||||
|   | |||||||
							
								
								
									
										152
									
								
								src/components/ha-selector/ha-selector-image.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								src/components/ha-selector/ha-selector-image.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | |||||||
|  | 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; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -107,15 +107,14 @@ export class HaMediaSelector extends LitElement { | |||||||
|         supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)); |         supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)); | ||||||
|  |  | ||||||
|     if (this.selector.media?.image_upload && !this.value) { |     if (this.selector.media?.image_upload && !this.value) { | ||||||
|       return html`${this.label ? html`<label>${this.label}</label>` : nothing} |       return html`<ha-picture-upload | ||||||
|         <ha-picture-upload |         .hass=${this.hass} | ||||||
|           .hass=${this.hass} |         .value=${null} | ||||||
|           .value=${null} |         .contentIdHelper=${this.selector.media?.content_id_helper} | ||||||
|           .contentIdHelper=${this.selector.media?.content_id_helper} |         select-media | ||||||
|           select-media |         full-media | ||||||
|           full-media |         @media-picked=${this._pictureUploadMediaPicked} | ||||||
|           @media-picked=${this._pictureUploadMediaPicked} |       ></ha-picture-upload>`; | ||||||
|         ></ha-picture-upload>`; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return html` |     return html` | ||||||
| @@ -142,7 +141,6 @@ export class HaMediaSelector extends LitElement { | |||||||
|           `} |           `} | ||||||
|       ${!supportsBrowse |       ${!supportsBrowse | ||||||
|         ? html` |         ? html` | ||||||
|             ${this.label ? html`<label>${this.label}</label>` : nothing} |  | ||||||
|             <ha-alert> |             <ha-alert> | ||||||
|               ${this.hass.localize( |               ${this.hass.localize( | ||||||
|                 "ui.components.selectors.media.browse_not_supported" |                 "ui.components.selectors.media.browse_not_supported" | ||||||
| @@ -156,8 +154,7 @@ export class HaMediaSelector extends LitElement { | |||||||
|               .computeHelper=${this._computeHelperCallback} |               .computeHelper=${this._computeHelperCallback} | ||||||
|             ></ha-form> |             ></ha-form> | ||||||
|           ` |           ` | ||||||
|         : html`${this.label ? html`<label>${this.label}</label>` : nothing} |         : html`<ha-card | ||||||
|             <ha-card |  | ||||||
|               outlined |               outlined | ||||||
|               tabindex="0" |               tabindex="0" | ||||||
|               role="button" |               role="button" | ||||||
|   | |||||||
| @@ -1,9 +1,4 @@ | |||||||
| import { | import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js"; | ||||||
|   mdiClose, |  | ||||||
|   mdiDelete, |  | ||||||
|   mdiDragHorizontalVariant, |  | ||||||
|   mdiPencil, |  | ||||||
| } from "@mdi/js"; |  | ||||||
| import { css, html, LitElement, nothing, type PropertyValues } from "lit"; | import { css, html, LitElement, nothing, type PropertyValues } from "lit"; | ||||||
| import { customElement, property, query } from "lit/decorators"; | import { customElement, property, query } from "lit/decorators"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| @@ -52,10 +47,9 @@ export class HaObjectSelector extends LitElement { | |||||||
|     const translationKey = this.selector.object?.translation_key; |     const translationKey = this.selector.object?.translation_key; | ||||||
|  |  | ||||||
|     if (this.localizeValue && translationKey) { |     if (this.localizeValue && translationKey) { | ||||||
|       const label = |       const label = this.localizeValue( | ||||||
|         this.localizeValue(`${translationKey}.fields.${schema.name}.name`) || |         `${translationKey}.fields.${schema.name}` | ||||||
|         // Fallback for backward compatibility |       ); | ||||||
|         this.localizeValue(`${translationKey}.fields.${schema.name}`); |  | ||||||
|       if (label) { |       if (label) { | ||||||
|         return label; |         return label; | ||||||
|       } |       } | ||||||
| @@ -63,20 +57,6 @@ export class HaObjectSelector extends LitElement { | |||||||
|     return this.selector.object?.fields?.[schema.name]?.label || schema.name; |     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) { |   private _renderItem(item: any, index: number) { | ||||||
|     const labelField = |     const labelField = | ||||||
|       this.selector.object!.label_field || |       this.selector.object!.label_field || | ||||||
| @@ -112,7 +92,7 @@ export class HaObjectSelector extends LitElement { | |||||||
|           ? html` |           ? html` | ||||||
|               <ha-svg-icon |               <ha-svg-icon | ||||||
|                 class="handle" |                 class="handle" | ||||||
|                 .path=${mdiDragHorizontalVariant} |                 .path=${mdiDrag} | ||||||
|                 slot="start" |                 slot="start" | ||||||
|               ></ha-svg-icon> |               ></ha-svg-icon> | ||||||
|             ` |             ` | ||||||
| @@ -229,7 +209,6 @@ export class HaObjectSelector extends LitElement { | |||||||
|       schema: this._schema(this.selector), |       schema: this._schema(this.selector), | ||||||
|       data: {}, |       data: {}, | ||||||
|       computeLabel: this._computeLabel, |       computeLabel: this._computeLabel, | ||||||
|       computeHelper: this._computeHelper, |  | ||||||
|       submitText: this.hass.localize("ui.common.add"), |       submitText: this.hass.localize("ui.common.add"), | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { mdiDragHorizontalVariant } from "@mdi/js"; | import { mdiDrag } from "@mdi/js"; | ||||||
| import { LitElement, css, html, nothing } from "lit"; | import { LitElement, css, html, nothing } from "lit"; | ||||||
| import { customElement, property, query } from "lit/decorators"; | import { customElement, property, query } from "lit/decorators"; | ||||||
| import { repeat } from "lit/directives/repeat"; | import { repeat } from "lit/directives/repeat"; | ||||||
| @@ -197,7 +197,7 @@ export class HaSelectSelector extends LitElement { | |||||||
|                             ? html` |                             ? html` | ||||||
|                                 <ha-svg-icon |                                 <ha-svg-icon | ||||||
|                                   slot="icon" |                                   slot="icon" | ||||||
|                                   .path=${mdiDragHorizontalVariant} |                                   .path=${mdiDrag} | ||||||
|                                 ></ha-svg-icon> |                                 ></ha-svg-icon> | ||||||
|                               ` |                               ` | ||||||
|                             : nothing} |                             : nothing} | ||||||
|   | |||||||
| @@ -36,7 +36,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) { | |||||||
|         .helper=${this.helper} |         .helper=${this.helper} | ||||||
|         .disabled=${this.disabled} |         .disabled=${this.disabled} | ||||||
|         .required=${this.required} |         .required=${this.required} | ||||||
|         .allowName=${this.selector.ui_state_content?.allow_name || false} |         .allowName=${this.selector.ui_state_content?.allow_name} | ||||||
|       ></ha-entity-state-content-picker> |       ></ha-entity-state-content-picker> | ||||||
|     `; |     `; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -34,6 +34,7 @@ const LOAD_ELEMENTS = { | |||||||
|   file: () => import("./ha-selector-file"), |   file: () => import("./ha-selector-file"), | ||||||
|   floor: () => import("./ha-selector-floor"), |   floor: () => import("./ha-selector-floor"), | ||||||
|   label: () => import("./ha-selector-label"), |   label: () => import("./ha-selector-label"), | ||||||
|  |   image: () => import("./ha-selector-image"), | ||||||
|   background: () => import("./ha-selector-background"), |   background: () => import("./ha-selector-background"), | ||||||
|   language: () => import("./ha-selector-language"), |   language: () => import("./ha-selector-language"), | ||||||
|   navigation: () => import("./ha-selector-navigation"), |   navigation: () => import("./ha-selector-navigation"), | ||||||
|   | |||||||
| @@ -94,9 +94,6 @@ export class HaServiceControl extends LitElement { | |||||||
|   @property({ attribute: "hide-picker", type: Boolean, reflect: true }) |   @property({ attribute: "hide-picker", type: Boolean, reflect: true }) | ||||||
|   public hidePicker = false; |   public hidePicker = false; | ||||||
|  |  | ||||||
|   @property({ attribute: "hide-description", type: Boolean }) |  | ||||||
|   public hideDescription = false; |  | ||||||
|  |  | ||||||
|   @state() private _value!: this["value"]; |   @state() private _value!: this["value"]; | ||||||
|  |  | ||||||
|   @state() private _checkedKeys = new Set(); |   @state() private _checkedKeys = new Set(); | ||||||
| @@ -472,136 +469,135 @@ export class HaServiceControl extends LitElement { | |||||||
|       serviceData?.description; |       serviceData?.description; | ||||||
|  |  | ||||||
|     return html`${this.hidePicker |     return html`${this.hidePicker | ||||||
|       ? nothing |         ? nothing | ||||||
|       : html`<ha-service-picker |         : html`<ha-service-picker | ||||||
|           .hass=${this.hass} |  | ||||||
|           .value=${this._value?.action} |  | ||||||
|           .disabled=${this.disabled} |  | ||||||
|           @value-changed=${this._serviceChanged} |  | ||||||
|           .showServiceId=${this.showServiceId} |  | ||||||
|         ></ha-service-picker>`} |  | ||||||
|     ${this.hideDescription |  | ||||||
|       ? nothing |  | ||||||
|       : html` |  | ||||||
|           <div class="description"> |  | ||||||
|             ${description ? html`<p>${description}</p>` : ""} |  | ||||||
|             ${this._manifest |  | ||||||
|               ? html` <a |  | ||||||
|                   href=${this._manifest.is_built_in |  | ||||||
|                     ? documentationUrl( |  | ||||||
|                         this.hass, |  | ||||||
|                         `/integrations/${this._manifest.domain}` |  | ||||||
|                       ) |  | ||||||
|                     : this._manifest.documentation} |  | ||||||
|                   title=${this.hass.localize( |  | ||||||
|                     "ui.components.service-control.integration_doc" |  | ||||||
|                   )} |  | ||||||
|                   target="_blank" |  | ||||||
|                   rel="noreferrer" |  | ||||||
|                 > |  | ||||||
|                   <ha-icon-button |  | ||||||
|                     .path=${mdiHelpCircle} |  | ||||||
|                     class="help-icon" |  | ||||||
|                   ></ha-icon-button> |  | ||||||
|                 </a>` |  | ||||||
|               : nothing} |  | ||||||
|           </div> |  | ||||||
|         `} |  | ||||||
|     ${serviceData && "target" in serviceData |  | ||||||
|       ? html`<ha-settings-row .narrow=${this.narrow}> |  | ||||||
|           ${hasOptional |  | ||||||
|             ? html`<div slot="prefix" class="checkbox-spacer"></div>` |  | ||||||
|             : ""} |  | ||||||
|           <span slot="heading" |  | ||||||
|             >${this.hass.localize("ui.components.service-control.target")}</span |  | ||||||
|           > |  | ||||||
|           <span slot="description" |  | ||||||
|             >${this.hass.localize( |  | ||||||
|               "ui.components.service-control.target_secondary" |  | ||||||
|             )}</span |  | ||||||
|           ><ha-selector |  | ||||||
|             .hass=${this.hass} |             .hass=${this.hass} | ||||||
|             .selector=${this._targetSelector( |             .value=${this._value?.action} | ||||||
|               serviceData.target as TargetSelector, |  | ||||||
|               this._value?.target |  | ||||||
|             )} |  | ||||||
|             .disabled=${this.disabled} |             .disabled=${this.disabled} | ||||||
|             @value-changed=${this._targetChanged} |             @value-changed=${this._serviceChanged} | ||||||
|             .value=${this._value?.target} |             .showServiceId=${this.showServiceId} | ||||||
|           ></ha-selector |           ></ha-service-picker>`} | ||||||
|         ></ha-settings-row>` |  | ||||||
|       : entityId |  | ||||||
|         ? html`<ha-entity-picker |  | ||||||
|             .hass=${this.hass} |  | ||||||
|             .disabled=${this.disabled} |  | ||||||
|             .value=${this._value?.data?.entity_id} |  | ||||||
|             .label=${this.hass.localize( |  | ||||||
|               `component.${domain}.services.${serviceName}.fields.entity_id.description` |  | ||||||
|             ) || entityId.description} |  | ||||||
|             @value-changed=${this._entityPicked} |  | ||||||
|             allow-custom-entity |  | ||||||
|           ></ha-entity-picker>` |  | ||||||
|         : ""} |  | ||||||
|     ${shouldRenderServiceDataYaml |  | ||||||
|       ? html`<ha-yaml-editor |  | ||||||
|           .hass=${this.hass} |  | ||||||
|           .label=${this.hass.localize( |  | ||||||
|             "ui.components.service-control.action_data" |  | ||||||
|           )} |  | ||||||
|           .name=${"data"} |  | ||||||
|           .readOnly=${this.disabled} |  | ||||||
|           .defaultValue=${this._value?.data} |  | ||||||
|           @value-changed=${this._dataChanged} |  | ||||||
|         ></ha-yaml-editor>` |  | ||||||
|       : serviceData?.fields.map((dataField) => { |  | ||||||
|           if (!dataField.fields) { |  | ||||||
|             return this._renderField( |  | ||||||
|               dataField, |  | ||||||
|               hasOptional, |  | ||||||
|               domain, |  | ||||||
|               serviceName, |  | ||||||
|               targetEntities |  | ||||||
|             ); |  | ||||||
|           } |  | ||||||
|  |  | ||||||
|           const fields = Object.entries(dataField.fields).map( |       <div class="description"> | ||||||
|             ([key, field]) => ({ key, ...field }) |         ${description ? html`<p>${description}</p>` : ""} | ||||||
|           ); |         ${this._manifest | ||||||
|  |           ? html` <a | ||||||
|           return fields.length && |               href=${this._manifest.is_built_in | ||||||
|             this._hasFilteredFields(fields, targetEntities) |                 ? documentationUrl( | ||||||
|             ? html`<ha-expansion-panel |                     this.hass, | ||||||
|                 left-chevron |                     `/integrations/${this._manifest.domain}` | ||||||
|                 .expanded=${!dataField.collapsed} |  | ||||||
|                 .header=${this.hass.localize( |  | ||||||
|                   `component.${domain}.services.${serviceName}.sections.${dataField.key}.name` |  | ||||||
|                 ) || |  | ||||||
|                 dataField.name || |  | ||||||
|                 dataField.key} |  | ||||||
|                 .secondary=${this._getSectionDescription( |  | ||||||
|                   dataField, |  | ||||||
|                   domain, |  | ||||||
|                   serviceName |  | ||||||
|                 )} |  | ||||||
|               > |  | ||||||
|                 <ha-service-section-icon |  | ||||||
|                   slot="icons" |  | ||||||
|                   .hass=${this.hass} |  | ||||||
|                   .service=${this._value!.action} |  | ||||||
|                   .section=${dataField.key} |  | ||||||
|                 ></ha-service-section-icon> |  | ||||||
|                 ${Object.entries(dataField.fields).map(([key, field]) => |  | ||||||
|                   this._renderField( |  | ||||||
|                     { key, ...field }, |  | ||||||
|                     hasOptional, |  | ||||||
|                     domain, |  | ||||||
|                     serviceName, |  | ||||||
|                     targetEntities |  | ||||||
|                   ) |                   ) | ||||||
|                 )} |                 : this._manifest.documentation} | ||||||
|               </ha-expansion-panel>` |               title=${this.hass.localize( | ||||||
|             : nothing; |                 "ui.components.service-control.integration_doc" | ||||||
|         })} `; |               )} | ||||||
|  |               target="_blank" | ||||||
|  |               rel="noreferrer" | ||||||
|  |             > | ||||||
|  |               <ha-icon-button | ||||||
|  |                 .path=${mdiHelpCircle} | ||||||
|  |                 class="help-icon" | ||||||
|  |               ></ha-icon-button> | ||||||
|  |             </a>` | ||||||
|  |           : nothing} | ||||||
|  |       </div> | ||||||
|  |       ${serviceData && "target" in serviceData | ||||||
|  |         ? html`<ha-settings-row .narrow=${this.narrow}> | ||||||
|  |             ${hasOptional | ||||||
|  |               ? html`<div slot="prefix" class="checkbox-spacer"></div>` | ||||||
|  |               : ""} | ||||||
|  |             <span slot="heading" | ||||||
|  |               >${this.hass.localize( | ||||||
|  |                 "ui.components.service-control.target" | ||||||
|  |               )}</span | ||||||
|  |             > | ||||||
|  |             <span slot="description" | ||||||
|  |               >${this.hass.localize( | ||||||
|  |                 "ui.components.service-control.target_secondary" | ||||||
|  |               )}</span | ||||||
|  |             ><ha-selector | ||||||
|  |               .hass=${this.hass} | ||||||
|  |               .selector=${this._targetSelector( | ||||||
|  |                 serviceData.target as TargetSelector, | ||||||
|  |                 this._value?.target | ||||||
|  |               )} | ||||||
|  |               .disabled=${this.disabled} | ||||||
|  |               @value-changed=${this._targetChanged} | ||||||
|  |               .value=${this._value?.target} | ||||||
|  |             ></ha-selector | ||||||
|  |           ></ha-settings-row>` | ||||||
|  |         : entityId | ||||||
|  |           ? html`<ha-entity-picker | ||||||
|  |               .hass=${this.hass} | ||||||
|  |               .disabled=${this.disabled} | ||||||
|  |               .value=${this._value?.data?.entity_id} | ||||||
|  |               .label=${this.hass.localize( | ||||||
|  |                 `component.${domain}.services.${serviceName}.fields.entity_id.description` | ||||||
|  |               ) || entityId.description} | ||||||
|  |               @value-changed=${this._entityPicked} | ||||||
|  |               allow-custom-entity | ||||||
|  |             ></ha-entity-picker>` | ||||||
|  |           : ""} | ||||||
|  |       ${shouldRenderServiceDataYaml | ||||||
|  |         ? html`<ha-yaml-editor | ||||||
|  |             .hass=${this.hass} | ||||||
|  |             .label=${this.hass.localize( | ||||||
|  |               "ui.components.service-control.action_data" | ||||||
|  |             )} | ||||||
|  |             .name=${"data"} | ||||||
|  |             .readOnly=${this.disabled} | ||||||
|  |             .defaultValue=${this._value?.data} | ||||||
|  |             @value-changed=${this._dataChanged} | ||||||
|  |           ></ha-yaml-editor>` | ||||||
|  |         : serviceData?.fields.map((dataField) => { | ||||||
|  |             if (!dataField.fields) { | ||||||
|  |               return this._renderField( | ||||||
|  |                 dataField, | ||||||
|  |                 hasOptional, | ||||||
|  |                 domain, | ||||||
|  |                 serviceName, | ||||||
|  |                 targetEntities | ||||||
|  |               ); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             const fields = Object.entries(dataField.fields).map( | ||||||
|  |               ([key, field]) => ({ key, ...field }) | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             return fields.length && | ||||||
|  |               this._hasFilteredFields(fields, targetEntities) | ||||||
|  |               ? html`<ha-expansion-panel | ||||||
|  |                   left-chevron | ||||||
|  |                   .expanded=${!dataField.collapsed} | ||||||
|  |                   .header=${this.hass.localize( | ||||||
|  |                     `component.${domain}.services.${serviceName}.sections.${dataField.key}.name` | ||||||
|  |                   ) || | ||||||
|  |                   dataField.name || | ||||||
|  |                   dataField.key} | ||||||
|  |                   .secondary=${this._getSectionDescription( | ||||||
|  |                     dataField, | ||||||
|  |                     domain, | ||||||
|  |                     serviceName | ||||||
|  |                   )} | ||||||
|  |                 > | ||||||
|  |                   <ha-service-section-icon | ||||||
|  |                     slot="icons" | ||||||
|  |                     .hass=${this.hass} | ||||||
|  |                     .service=${this._value!.action} | ||||||
|  |                     .section=${dataField.key} | ||||||
|  |                   ></ha-service-section-icon> | ||||||
|  |                   ${Object.entries(dataField.fields).map(([key, field]) => | ||||||
|  |                     this._renderField( | ||||||
|  |                       { key, ...field }, | ||||||
|  |                       hasOptional, | ||||||
|  |                       domain, | ||||||
|  |                       serviceName, | ||||||
|  |                       targetEntities | ||||||
|  |                     ) | ||||||
|  |                   )} | ||||||
|  |                 </ha-expansion-panel>` | ||||||
|  |               : nothing; | ||||||
|  |           })} `; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _getSectionDescription( |   private _getSectionDescription( | ||||||
|   | |||||||
| @@ -53,7 +53,7 @@ class HaServicePicker extends LitElement { | |||||||
|     item, |     item, | ||||||
|     { index } |     { index } | ||||||
|   ) => html` |   ) => html` | ||||||
|     <ha-combo-box-item type="button" .borderTop=${index !== 0}> |     <ha-combo-box-item type="button" border-top .borderTop=${index !== 0}> | ||||||
|       <ha-service-icon |       <ha-service-icon | ||||||
|         slot="start" |         slot="start" | ||||||
|         .hass=${this.hass} |         .hass=${this.hass} | ||||||
| @@ -162,9 +162,7 @@ class HaServicePicker extends LitElement { | |||||||
|             const description = |             const description = | ||||||
|               this.hass.localize( |               this.hass.localize( | ||||||
|                 `component.${domain}.services.${service}.description` |                 `component.${domain}.services.${service}.description` | ||||||
|               ) || |               ) || services[domain][service].description; | ||||||
|               services[domain][service].description || |  | ||||||
|               ""; |  | ||||||
|  |  | ||||||
|             items.push({ |             items.push({ | ||||||
|               id: serviceId, |               id: serviceId, | ||||||
|   | |||||||
| @@ -29,7 +29,6 @@ import memoizeOne from "memoize-one"; | |||||||
| import { fireEvent } from "../common/dom/fire_event"; | import { fireEvent } from "../common/dom/fire_event"; | ||||||
| import { toggleAttribute } from "../common/dom/toggle_attribute"; | import { toggleAttribute } from "../common/dom/toggle_attribute"; | ||||||
| import { stringCompare } from "../common/string/compare"; | import { stringCompare } from "../common/string/compare"; | ||||||
| import { computeRTL } from "../common/util/compute_rtl"; |  | ||||||
| import { throttle } from "../common/util/throttle"; | import { throttle } from "../common/util/throttle"; | ||||||
| import { subscribeFrontendUserData } from "../data/frontend"; | import { subscribeFrontendUserData } from "../data/frontend"; | ||||||
| import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; | import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; | ||||||
| @@ -537,17 +536,11 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _renderUserItem(selectedPanel: string) { |   private _renderUserItem(selectedPanel: string) { | ||||||
|     const isRTL = computeRTL(this.hass); |  | ||||||
|  |  | ||||||
|     return html` |     return html` | ||||||
|       <ha-md-list-item |       <ha-md-list-item | ||||||
|         href="/profile" |         href="/profile" | ||||||
|         type="link" |         type="link" | ||||||
|         class=${classMap({ |         class="user ${selectedPanel === "profile" ? " selected" : ""}" | ||||||
|           user: true, |  | ||||||
|           selected: selectedPanel === "profile", |  | ||||||
|           rtl: isRTL, |  | ||||||
|         })} |  | ||||||
|         @mouseenter=${this._itemMouseEnter} |         @mouseenter=${this._itemMouseEnter} | ||||||
|         @mouseleave=${this._itemMouseLeave} |         @mouseleave=${this._itemMouseLeave} | ||||||
|       > |       > | ||||||
| @@ -673,7 +666,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|     tooltip.style.display = "block"; |     tooltip.style.display = "block"; | ||||||
|     tooltip.style.position = "fixed"; |     tooltip.style.position = "fixed"; | ||||||
|     tooltip.style.top = `${top}px`; |     tooltip.style.top = `${top}px`; | ||||||
|     tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, var(--ha-space-0)))`; |     tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private _hideTooltip() { |   private _hideTooltip() { | ||||||
| @@ -712,17 +705,13 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|           background-color: var(--sidebar-background-color); |           background-color: var(--sidebar-background-color); | ||||||
|           width: 100%; |           width: 100%; | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|           padding-bottom: calc( |           padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px)); | ||||||
|             14px + var(--safe-area-inset-bottom, var(--ha-space-0)) |  | ||||||
|           ); |  | ||||||
|         } |         } | ||||||
|         .menu { |         .menu { | ||||||
|           height: calc( |           height: calc(var(--header-height) + var(--safe-area-inset-top, 0px)); | ||||||
|             var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0)) |  | ||||||
|           ); |  | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|           display: flex; |           display: flex; | ||||||
|           padding: 0 var(--ha-space-1); |           padding: 0 4px; | ||||||
|           border-bottom: 1px solid transparent; |           border-bottom: 1px solid transparent; | ||||||
|           white-space: nowrap; |           white-space: nowrap; | ||||||
|           font-weight: var(--ha-font-weight-normal); |           font-weight: var(--ha-font-weight-normal); | ||||||
| @@ -737,17 +726,13 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|           ); |           ); | ||||||
|           font-size: var(--ha-font-size-xl); |           font-size: var(--ha-font-size-xl); | ||||||
|           align-items: center; |           align-items: center; | ||||||
|           padding-left: calc( |           padding-left: calc(4px + var(--safe-area-inset-left, 0px)); | ||||||
|             var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0)) |           padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px)); | ||||||
|           ); |  | ||||||
|           padding-inline-start: calc( |  | ||||||
|             var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0)) |  | ||||||
|           ); |  | ||||||
|           padding-inline-end: initial; |           padding-inline-end: initial; | ||||||
|           padding-top: var(--safe-area-inset-top, var(--ha-space-0)); |           padding-top: var(--safe-area-inset-top, 0px); | ||||||
|         } |         } | ||||||
|         :host([expanded]) .menu { |         :host([expanded]) .menu { | ||||||
|           width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0))); |           width: calc(256px + var(--safe-area-inset-left, 0px)); | ||||||
|         } |         } | ||||||
|         :host([narrow][expanded]) .menu { |         :host([narrow][expanded]) .menu { | ||||||
|           width: 100%; |           width: 100%; | ||||||
| @@ -763,8 +748,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|           display: none; |           display: none; | ||||||
|         } |         } | ||||||
|         :host([narrow]) .title { |         :host([narrow]) .title { | ||||||
|           margin: var(--ha-space-0); |           margin: 0; | ||||||
|           padding: var(--ha-space-0) var(--ha-space-4); |           padding: 0 16px; | ||||||
|         } |         } | ||||||
|         :host([expanded]) .title { |         :host([expanded]) .title { | ||||||
|           display: initial; |           display: initial; | ||||||
| @@ -776,16 +761,13 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|         ha-fade-in, |         ha-fade-in, | ||||||
|         ha-md-list { |         ha-md-list { | ||||||
|           height: calc( |           height: calc( | ||||||
|             100% - var(--header-height) - var( |             100% - var(--header-height) - var(--safe-area-inset-top, 0px) - | ||||||
|                 --safe-area-inset-top, |  | ||||||
|                 var(--ha-space-0) |  | ||||||
|               ) - |  | ||||||
|               132px |               132px | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         ha-fade-in { |         ha-fade-in { | ||||||
|           padding: var(--ha-space-1) var(--ha-space-0); |           padding: 4px 0; | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|           display: flex; |           display: flex; | ||||||
|           justify-content: center; |           justify-content: center; | ||||||
| @@ -795,29 +777,29 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|         ha-md-list { |         ha-md-list { | ||||||
|           overflow-x: hidden; |           overflow-x: hidden; | ||||||
|           background: none; |           background: none; | ||||||
|           margin-left: var(--safe-area-inset-left, var(--ha-space-0)); |           margin-left: var(--safe-area-inset-left, 0px); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         ha-md-list-item { |         ha-md-list-item { | ||||||
|           flex-shrink: 0; |           flex-shrink: 0; | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|           margin: var(--ha-space-1); |           margin: 4px; | ||||||
|           border-radius: var(--ha-border-radius-sm); |           border-radius: var(--ha-border-radius-sm); | ||||||
|           --md-list-item-one-line-container-height: var(--ha-space-10); |           --md-list-item-one-line-container-height: 40px; | ||||||
|           --md-list-item-top-space: 0; |           --md-list-item-top-space: 0; | ||||||
|           --md-list-item-bottom-space: 0; |           --md-list-item-bottom-space: 0; | ||||||
|           width: var(--ha-space-12); |           width: 48px; | ||||||
|           position: relative; |           position: relative; | ||||||
|           --md-list-item-label-text-color: var(--sidebar-text-color); |           --md-list-item-label-text-color: var(--sidebar-text-color); | ||||||
|           --md-list-item-leading-space: var(--ha-space-3); |           --md-list-item-leading-space: 12px; | ||||||
|           --md-list-item-trailing-space: var(--ha-space-3); |           --md-list-item-trailing-space: 12px; | ||||||
|           --md-list-item-leading-icon-size: var(--ha-space-6); |           --md-list-item-leading-icon-size: 24px; | ||||||
|         } |         } | ||||||
|         :host([expanded]) ha-md-list-item { |         :host([expanded]) ha-md-list-item { | ||||||
|           width: 248px; |           width: 248px; | ||||||
|         } |         } | ||||||
|         :host([narrow][expanded]) ha-md-list-item { |         :host([narrow][expanded]) ha-md-list-item { | ||||||
|           width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0))); |           width: calc(240px - var(--safe-area-inset-left, 0px)); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         ha-md-list-item.selected { |         ha-md-list-item.selected { | ||||||
| @@ -841,7 +823,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|  |  | ||||||
|         ha-icon[slot="start"], |         ha-icon[slot="start"], | ||||||
|         ha-svg-icon[slot="start"] { |         ha-svg-icon[slot="start"] { | ||||||
|           width: var(--ha-space-6); |           width: 24px; | ||||||
|           flex-shrink: 0; |           flex-shrink: 0; | ||||||
|           color: var(--sidebar-icon-color); |           color: var(--sidebar-icon-color); | ||||||
|         } |         } | ||||||
| @@ -874,7 +856,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|           display: flex; |           display: flex; | ||||||
|           justify-content: center; |           justify-content: center; | ||||||
|           align-items: center; |           align-items: center; | ||||||
|           min-width: var(--ha-space-2); |           min-width: 8px; | ||||||
|           border-radius: var(--ha-border-radius-xl); |           border-radius: var(--ha-border-radius-xl); | ||||||
|           font-weight: var(--ha-font-weight-normal); |           font-weight: var(--ha-font-weight-normal); | ||||||
|           line-height: normal; |           line-height: normal; | ||||||
| @@ -885,26 +867,22 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|  |  | ||||||
|         ha-svg-icon + .badge { |         ha-svg-icon + .badge { | ||||||
|           position: absolute; |           position: absolute; | ||||||
|           top: var(--ha-space-1); |           top: 4px; | ||||||
|           left: 26px; |           left: 26px; | ||||||
|           border-radius: var(--ha-border-radius-md); |           border-radius: var(--ha-border-radius-md); | ||||||
|           font-size: 0.65em; |           font-size: 0.65em; | ||||||
|           line-height: var(--ha-line-height-expanded); |           line-height: var(--ha-line-height-expanded); | ||||||
|           padding: var(--ha-space-0) var(--ha-space-1); |           padding: 0 4px; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         ha-md-list-item.user { |         ha-md-list-item.user { | ||||||
|           --md-list-item-leading-icon-size: var(--ha-space-10); |           --md-list-item-leading-icon-size: 40px; | ||||||
|           --md-list-item-leading-space: var(--ha-space-1); |           --md-list-item-leading-space: 4px; | ||||||
|         } |  | ||||||
|  |  | ||||||
|         ha-md-list-item.user.rtl { |  | ||||||
|           --md-list-item-leading-space: var(--ha-space-3); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         ha-user-badge { |         ha-user-badge { | ||||||
|           flex-shrink: 0; |           flex-shrink: 0; | ||||||
|           margin-right: calc(var(--ha-space-2) * -1); |           margin-right: -8px; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         .spacer { |         .spacer { | ||||||
| @@ -916,7 +894,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|           color: var(--sidebar-text-color); |           color: var(--sidebar-text-color); | ||||||
|           font-size: var(--ha-font-size-m); |           font-size: var(--ha-font-size-m); | ||||||
|           font-weight: var(--ha-font-weight-medium); |           font-weight: var(--ha-font-weight-medium); | ||||||
|           padding: var(--ha-space-4); |           padding: 16px; | ||||||
|           white-space: nowrap; |           white-space: nowrap; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -928,7 +906,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | |||||||
|           white-space: nowrap; |           white-space: nowrap; | ||||||
|           color: var(--sidebar-background-color); |           color: var(--sidebar-background-color); | ||||||
|           background-color: var(--sidebar-text-color); |           background-color: var(--sidebar-text-color); | ||||||
|           padding: var(--ha-space-1); |           padding: 4px; | ||||||
|           font-weight: var(--ha-font-weight-medium); |           font-weight: var(--ha-font-weight-medium); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -59,33 +59,12 @@ export class HaSlider extends Slider { | |||||||
|           background-color: var(--ha-slider-thumb-color, var(--primary-color)); |           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, | ||||||
|         #slider:focus-visible:not(.disabled) #thumb-min, |         #slider:focus-visible:not(.disabled) #thumb-min, | ||||||
|         #slider:focus-visible:not(.disabled) #thumb-max { |         #slider:focus-visible:not(.disabled) #thumb-max { | ||||||
|           outline: var(--wa-focus-ring); |           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 { |         #indicator { | ||||||
|           background-color: var( |           background-color: var( | ||||||
|             --ha-slider-indicator-color, |             --ha-slider-indicator-color, | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										97
									
								
								src/components/ha-trigger-icon.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/components/ha-trigger-icon.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | import { | ||||||
|  |   mdiAvTimer, | ||||||
|  |   mdiCalendar, | ||||||
|  |   mdiClockOutline, | ||||||
|  |   mdiCodeBraces, | ||||||
|  |   mdiDevices, | ||||||
|  |   mdiFormatListBulleted, | ||||||
|  |   mdiGestureDoubleTap, | ||||||
|  |   mdiHomeAssistant, | ||||||
|  |   mdiMapMarker, | ||||||
|  |   mdiMapMarkerRadius, | ||||||
|  |   mdiMessageAlert, | ||||||
|  |   mdiMicrophoneMessage, | ||||||
|  |   mdiNfcVariant, | ||||||
|  |   mdiNumeric, | ||||||
|  |   mdiStateMachine, | ||||||
|  |   mdiSwapHorizontal, | ||||||
|  |   mdiWeatherSunny, | ||||||
|  |   mdiWebhook, | ||||||
|  | } from "@mdi/js"; | ||||||
|  | import { html, LitElement, nothing } from "lit"; | ||||||
|  | import { customElement, property } from "lit/decorators"; | ||||||
|  | import { until } from "lit/directives/until"; | ||||||
|  | import { computeDomain } from "../common/entity/compute_domain"; | ||||||
|  | import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons"; | ||||||
|  | import type { HomeAssistant } from "../types"; | ||||||
|  | import "./ha-icon"; | ||||||
|  | import "./ha-svg-icon"; | ||||||
|  |  | ||||||
|  | export const TRIGGER_ICONS = { | ||||||
|  |   calendar: mdiCalendar, | ||||||
|  |   device: mdiDevices, | ||||||
|  |   event: mdiGestureDoubleTap, | ||||||
|  |   state: mdiStateMachine, | ||||||
|  |   geo_location: mdiMapMarker, | ||||||
|  |   homeassistant: mdiHomeAssistant, | ||||||
|  |   mqtt: mdiSwapHorizontal, | ||||||
|  |   numeric_state: mdiNumeric, | ||||||
|  |   sun: mdiWeatherSunny, | ||||||
|  |   conversation: mdiMicrophoneMessage, | ||||||
|  |   tag: mdiNfcVariant, | ||||||
|  |   template: mdiCodeBraces, | ||||||
|  |   time: mdiClockOutline, | ||||||
|  |   time_pattern: mdiAvTimer, | ||||||
|  |   webhook: mdiWebhook, | ||||||
|  |   persistent_notification: mdiMessageAlert, | ||||||
|  |   zone: mdiMapMarkerRadius, | ||||||
|  |   list: mdiFormatListBulleted, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @customElement("ha-trigger-icon") | ||||||
|  | export class HaTriggerIcon extends LitElement { | ||||||
|  |   @property({ attribute: false }) public hass!: HomeAssistant; | ||||||
|  |  | ||||||
|  |   @property() public trigger?: string; | ||||||
|  |  | ||||||
|  |   @property() public icon?: string; | ||||||
|  |  | ||||||
|  |   protected render() { | ||||||
|  |     if (this.icon) { | ||||||
|  |       return html`<ha-icon .icon=${this.icon}></ha-icon>`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!this.trigger) { | ||||||
|  |       return nothing; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!this.hass) { | ||||||
|  |       return this._renderFallback(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const icon = triggerIcon(this.hass, this.trigger).then((icn) => { | ||||||
|  |       if (icn) { | ||||||
|  |         return html`<ha-icon .icon=${icn}></ha-icon>`; | ||||||
|  |       } | ||||||
|  |       return this._renderFallback(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return html`${until(icon)}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private _renderFallback() { | ||||||
|  |     const domain = computeDomain(this.trigger!); | ||||||
|  |  | ||||||
|  |     return html` | ||||||
|  |       <ha-svg-icon | ||||||
|  |         .path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]} | ||||||
|  |       ></ha-svg-icon> | ||||||
|  |     `; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | declare global { | ||||||
|  |   interface HTMLElementTagNameMap { | ||||||
|  |     "ha-trigger-icon": HaTriggerIcon; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,18 +1,12 @@ | |||||||
|  | import { css, html, LitElement, nothing } from "lit"; | ||||||
|  | import { customElement, property, state } from "lit/decorators"; | ||||||
| import "@home-assistant/webawesome/dist/components/dialog/dialog"; | import "@home-assistant/webawesome/dist/components/dialog/dialog"; | ||||||
| import { mdiClose } from "@mdi/js"; | 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-dialog-header"; | ||||||
| import "./ha-icon-button"; | import "./ha-icon-button"; | ||||||
|  | import type { HomeAssistant } from "../types"; | ||||||
|  | import { fireEvent } from "../common/dom/fire_event"; | ||||||
|  | import { haStyleScrollbar } from "../resources/styles"; | ||||||
|  |  | ||||||
| export type DialogWidth = "small" | "medium" | "large" | "full"; | export type DialogWidth = "small" | "medium" | "large" | "full"; | ||||||
|  |  | ||||||
| @@ -96,11 +90,6 @@ export class HaWaDialog extends LitElement { | |||||||
|   @state() |   @state() | ||||||
|   private _open = false; |   private _open = false; | ||||||
|  |  | ||||||
|   @query(".body") public bodyContainer!: HTMLDivElement; |  | ||||||
|  |  | ||||||
|   @state() |  | ||||||
|   private _bodyScrolled = false; |  | ||||||
|  |  | ||||||
|   protected updated( |   protected updated( | ||||||
|     changedProperties: Map<string | number | symbol, unknown> |     changedProperties: Map<string | number | symbol, unknown> | ||||||
|   ): void { |   ): void { | ||||||
| @@ -118,14 +107,10 @@ export class HaWaDialog extends LitElement { | |||||||
|         .lightDismiss=${!this.preventScrimClose} |         .lightDismiss=${!this.preventScrimClose} | ||||||
|         without-header |         without-header | ||||||
|         @wa-show=${this._handleShow} |         @wa-show=${this._handleShow} | ||||||
|         @wa-after-show=${this._handleAfterShow} |  | ||||||
|         @wa-after-hide=${this._handleAfterHide} |         @wa-after-hide=${this._handleAfterHide} | ||||||
|       > |       > | ||||||
|         <slot name="header"> |         <slot name="header"> | ||||||
|           <ha-dialog-header |           <ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}> | ||||||
|             .subtitlePosition=${this.headerSubtitlePosition} |  | ||||||
|             .showBorder=${this._bodyScrolled} |  | ||||||
|           > |  | ||||||
|             <slot name="headerNavigationIcon" slot="navigationIcon"> |             <slot name="headerNavigationIcon" slot="navigationIcon"> | ||||||
|               <ha-icon-button |               <ha-icon-button | ||||||
|                 data-dialog="close" |                 data-dialog="close" | ||||||
| @@ -144,7 +129,7 @@ export class HaWaDialog extends LitElement { | |||||||
|             <slot name="headerActionItems" slot="actionItems"></slot> |             <slot name="headerActionItems" slot="actionItems"></slot> | ||||||
|           </ha-dialog-header> |           </ha-dialog-header> | ||||||
|         </slot> |         </slot> | ||||||
|         <div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}> |         <div class="body ha-scrollbar"> | ||||||
|           <slot></slot> |           <slot></slot> | ||||||
|         </div> |         </div> | ||||||
|         <slot name="footer" slot="footer"></slot> |         <slot name="footer" slot="footer"></slot> | ||||||
| @@ -161,10 +146,6 @@ export class HaWaDialog extends LitElement { | |||||||
|     (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); |     (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   private _handleAfterShow = () => { |  | ||||||
|     fireEvent(this, "after-show"); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   private _handleAfterHide = () => { |   private _handleAfterHide = () => { | ||||||
|     this._open = false; |     this._open = false; | ||||||
|     fireEvent(this, "closed"); |     fireEvent(this, "closed"); | ||||||
| @@ -175,11 +156,6 @@ export class HaWaDialog extends LitElement { | |||||||
|     this._open = false; |     this._open = false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @eventOptions({ passive: true }) |  | ||||||
|   private _handleBodyScroll(ev: Event) { |  | ||||||
|     this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   static styles = [ |   static styles = [ | ||||||
|     haStyleScrollbar, |     haStyleScrollbar, | ||||||
|     css` |     css` | ||||||
| @@ -196,7 +172,7 @@ export class HaWaDialog extends LitElement { | |||||||
|             ) |             ) | ||||||
|           ) |           ) | ||||||
|         ); |         ); | ||||||
|         --width: min(var(--ha-dialog-width-md, 580px), var(--full-width)); |         --width: var(--ha-dialog-width-md, min(580px, var(--full-width))); | ||||||
|         --spacing: var(--dialog-content-padding, var(--ha-space-6)); |         --spacing: var(--dialog-content-padding, var(--ha-space-6)); | ||||||
|         --show-duration: var(--ha-dialog-show-duration, 200ms); |         --show-duration: var(--ha-dialog-show-duration, 200ms); | ||||||
|         --hide-duration: var(--ha-dialog-hide-duration, 200ms); |         --hide-duration: var(--ha-dialog-hide-duration, 200ms); | ||||||
| @@ -217,11 +193,11 @@ export class HaWaDialog extends LitElement { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       :host([width="small"]) wa-dialog { |       :host([width="small"]) wa-dialog { | ||||||
|         --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width)); |         --width: var(--ha-dialog-width-sm, min(320px, var(--full-width))); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       :host([width="large"]) wa-dialog { |       :host([width="large"]) wa-dialog { | ||||||
|         --width: min(var(--ha-dialog-width-lg, 720px), var(--full-width)); |         --width: var(--ha-dialog-width-lg, min(720px, var(--full-width))); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       :host([width="full"]) wa-dialog { |       :host([width="full"]) wa-dialog { | ||||||
| @@ -235,7 +211,6 @@ export class HaWaDialog extends LitElement { | |||||||
|           --ha-dialog-max-height, |           --ha-dialog-max-height, | ||||||
|           calc(100% - var(--ha-space-20)) |           calc(100% - var(--ha-space-20)) | ||||||
|         ); |         ); | ||||||
|         min-height: var(--ha-dialog-min-height); |  | ||||||
|         position: var(--dialog-surface-position, relative); |         position: var(--dialog-surface-position, relative); | ||||||
|         margin-top: var(--dialog-surface-margin-top, auto); |         margin-top: var(--dialog-surface-margin-top, auto); | ||||||
|         display: flex; |         display: flex; | ||||||
| @@ -272,7 +247,10 @@ export class HaWaDialog extends LitElement { | |||||||
|       .header-title { |       .header-title { | ||||||
|         margin: 0; |         margin: 0; | ||||||
|         margin-bottom: 0; |         margin-bottom: 0; | ||||||
|         color: var(--ha-dialog-header-title-color, var(--primary-text-color)); |         color: var( | ||||||
|  |           --ha-dialog-header-title-color, | ||||||
|  |           var(--ha-color-on-surface-default, var(--primary-text-color)) | ||||||
|  |         ); | ||||||
|         font-size: var( |         font-size: var( | ||||||
|           --ha-dialog-header-title-font-size, |           --ha-dialog-header-title-font-size, | ||||||
|           var(--ha-font-size-2xl) |           var(--ha-font-size-2xl) | ||||||
| @@ -309,7 +287,6 @@ export class HaWaDialog extends LitElement { | |||||||
|       } |       } | ||||||
|       :host([flexcontent]) .body { |       :host([flexcontent]) .body { | ||||||
|         max-width: 100%; |         max-width: 100%; | ||||||
|         flex: 1; |  | ||||||
|         display: flex; |         display: flex; | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
|       } |       } | ||||||
| @@ -338,7 +315,6 @@ declare global { | |||||||
|  |  | ||||||
|   interface HASSDomEvents { |   interface HASSDomEvents { | ||||||
|     opened: undefined; |     opened: undefined; | ||||||
|     "after-show": undefined; |  | ||||||
|     closed: undefined; |     closed: undefined; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -321,10 +321,6 @@ class HaWebRtcPlayer extends LitElement { | |||||||
|     if (!this._remoteStream) { |     if (!this._remoteStream) { | ||||||
|       return; |       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); |     this._remoteStream.addTrack(event.track); | ||||||
|     if (!this.hasUpdated) { |     if (!this.hasUpdated) { | ||||||
|       await this.updateComplete; |       await this.updateComplete; | ||||||
|   | |||||||
| @@ -15,7 +15,6 @@ import { classMap } from "lit/directives/class-map"; | |||||||
| import { styleMap } from "lit/directives/style-map"; | import { styleMap } from "lit/directives/style-map"; | ||||||
| import { until } from "lit/directives/until"; | import { until } from "lit/directives/until"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
| import { slugify } from "../../common/string/slugify"; |  | ||||||
| import { debounce } from "../../common/util/debounce"; | import { debounce } from "../../common/util/debounce"; | ||||||
| import { isUnavailableState } from "../../data/entity"; | import { isUnavailableState } from "../../data/entity"; | ||||||
| import type { | import type { | ||||||
| @@ -694,12 +693,10 @@ export class HaMediaPlayerBrowse extends LitElement { | |||||||
|                 ` |                 ` | ||||||
|               : ""} |               : ""} | ||||||
|           </div> |           </div> | ||||||
|           <ha-tooltip .for="grid-${slugify(child.title)}" distance="-4"> |           <ha-tooltip .for="grid-${child.title}" distance="-4"> | ||||||
|             ${child.title} |             ${child.title} | ||||||
|           </ha-tooltip> |           </ha-tooltip> | ||||||
|           <div .id="grid-${slugify(child.title)}" class="title"> |           <div .id="grid-${child.title}" class="title">${child.title}</div> | ||||||
|             ${child.title} |  | ||||||
|           </div> |  | ||||||
|         </ha-card> |         </ha-card> | ||||||
|       </div> |       </div> | ||||||
|     `; |     `; | ||||||
|   | |||||||
| @@ -1,76 +0,0 @@ | |||||||
| 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; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,28 +0,0 @@ | |||||||
| 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, |  | ||||||
|   }); |  | ||||||
| @@ -1,113 +0,0 @@ | |||||||
| 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; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,694 +0,0 @@ | |||||||
| 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; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,355 +0,0 @@ | |||||||
| import { consume } from "@lit/context"; |  | ||||||
| // @ts-ignore |  | ||||||
| import chipStyles from "@material/chips/dist/mdc.chips.min.css"; |  | ||||||
| import { |  | ||||||
|   mdiClose, |  | ||||||
|   mdiDevices, |  | ||||||
|   mdiHome, |  | ||||||
|   mdiLabel, |  | ||||||
|   mdiTextureBox, |  | ||||||
|   mdiUnfoldMoreVertical, |  | ||||||
| } from "@mdi/js"; |  | ||||||
| import { css, html, LitElement, nothing, unsafeCSS } from "lit"; |  | ||||||
| import { customElement, property, state } from "lit/decorators"; |  | ||||||
| import { classMap } from "lit/directives/class-map"; |  | ||||||
| import memoizeOne from "memoize-one"; |  | ||||||
| import { computeCssColor } from "../../common/color/compute-color"; |  | ||||||
| import { hex2rgb } from "../../common/color/convert-color"; |  | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; |  | ||||||
| import { slugify } from "../../common/string/slugify"; |  | ||||||
| import { |  | ||||||
|   computeDeviceName, |  | ||||||
|   computeDeviceNameDisplay, |  | ||||||
| } from "../../common/entity/compute_device_name"; |  | ||||||
| import { computeDomain } from "../../common/entity/compute_domain"; |  | ||||||
| import { computeEntityName } from "../../common/entity/compute_entity_name"; |  | ||||||
| import { getEntityContext } from "../../common/entity/context/get_entity_context"; |  | ||||||
| import { getConfigEntry } from "../../data/config_entries"; |  | ||||||
| import { labelsContext } from "../../data/context"; |  | ||||||
| import { domainToName } from "../../data/integration"; |  | ||||||
| import type { LabelRegistryEntry } from "../../data/label_registry"; |  | ||||||
| import type { TargetType } from "../../data/target"; |  | ||||||
| import type { HomeAssistant } from "../../types"; |  | ||||||
| import { brandsUrl } from "../../util/brands-url"; |  | ||||||
| import { floorDefaultIconPath } from "../ha-floor-icon"; |  | ||||||
| import "../ha-icon"; |  | ||||||
| import "../ha-icon-button"; |  | ||||||
| import "../ha-md-list"; |  | ||||||
| import "../ha-md-list-item"; |  | ||||||
| import "../ha-state-icon"; |  | ||||||
| import "../ha-tooltip"; |  | ||||||
|  |  | ||||||
| @customElement("ha-target-picker-value-chip") |  | ||||||
| export class HaTargetPickerValueChip extends LitElement { |  | ||||||
|   @property({ attribute: false }) public hass!: HomeAssistant; |  | ||||||
|  |  | ||||||
|   @property() public type!: TargetType; |  | ||||||
|  |  | ||||||
|   @property({ attribute: "item-id" }) public itemId!: string; |  | ||||||
|  |  | ||||||
|   @state() private _domainName?: string; |  | ||||||
|  |  | ||||||
|   @state() private _iconImg?: string; |  | ||||||
|  |  | ||||||
|   @state() |  | ||||||
|   @consume({ context: labelsContext, subscribe: true }) |  | ||||||
|   _labelRegistry!: LabelRegistryEntry[]; |  | ||||||
|  |  | ||||||
|   protected render() { |  | ||||||
|     const { name, iconPath, fallbackIconPath, stateObject, color } = |  | ||||||
|       this._itemData(this.type, this.itemId); |  | ||||||
|  |  | ||||||
|     return html` |  | ||||||
|       <div |  | ||||||
|         class="mdc-chip ${classMap({ |  | ||||||
|           [this.type]: true, |  | ||||||
|         })}" |  | ||||||
|         style=${color |  | ||||||
|           ? `--color: rgb(${color}); --background-color: rgba(${color}, .5)` |  | ||||||
|           : ""} |  | ||||||
|       > |  | ||||||
|         ${iconPath |  | ||||||
|           ? html`<ha-icon |  | ||||||
|               class="mdc-chip__icon mdc-chip__icon--leading" |  | ||||||
|               .icon=${iconPath} |  | ||||||
|             ></ha-icon>` |  | ||||||
|           : this._iconImg |  | ||||||
|             ? html`<img |  | ||||||
|                 class="mdc-chip__icon mdc-chip__icon--leading" |  | ||||||
|                 alt=${this._domainName || ""} |  | ||||||
|                 crossorigin="anonymous" |  | ||||||
|                 referrerpolicy="no-referrer" |  | ||||||
|                 src=${this._iconImg} |  | ||||||
|               />` |  | ||||||
|             : fallbackIconPath |  | ||||||
|               ? html`<ha-svg-icon |  | ||||||
|                   class="mdc-chip__icon mdc-chip__icon--leading" |  | ||||||
|                   .path=${fallbackIconPath} |  | ||||||
|                 ></ha-svg-icon>` |  | ||||||
|               : stateObject |  | ||||||
|                 ? html`<ha-state-icon |  | ||||||
|                     class="mdc-chip__icon mdc-chip__icon--leading" |  | ||||||
|                     .hass=${this.hass} |  | ||||||
|                     .stateObj=${stateObject} |  | ||||||
|                   ></ha-state-icon>` |  | ||||||
|                 : nothing} |  | ||||||
|         <span role="gridcell"> |  | ||||||
|           <span role="button" tabindex="0" class="mdc-chip__primary-action"> |  | ||||||
|             <span id="title-${this.itemId}" class="mdc-chip__text" |  | ||||||
|               >${name}</span |  | ||||||
|             > |  | ||||||
|           </span> |  | ||||||
|         </span> |  | ||||||
|         ${this.type === "entity" |  | ||||||
|           ? nothing |  | ||||||
|           : html`<span role="gridcell"> |  | ||||||
|               <ha-tooltip .for="expand-${slugify(this.itemId)}" |  | ||||||
|                 >${this.hass.localize( |  | ||||||
|                   `ui.components.target-picker.expand_${this.type}_id` |  | ||||||
|                 )} |  | ||||||
|               </ha-tooltip> |  | ||||||
|               <ha-icon-button |  | ||||||
|                 class="expand-btn mdc-chip__icon mdc-chip__icon--trailing" |  | ||||||
|                 .label=${this.hass.localize( |  | ||||||
|                   "ui.components.target-picker.expand" |  | ||||||
|                 )} |  | ||||||
|                 .path=${mdiUnfoldMoreVertical} |  | ||||||
|                 hide-title |  | ||||||
|                 .id="expand-${slugify(this.itemId)}" |  | ||||||
|                 .type=${this.type} |  | ||||||
|                 @click=${this._handleExpand} |  | ||||||
|               ></ha-icon-button> |  | ||||||
|             </span>`} |  | ||||||
|         <span role="gridcell"> |  | ||||||
|           <ha-tooltip .for="remove-${slugify(this.itemId)}"> |  | ||||||
|             ${this.hass.localize( |  | ||||||
|               `ui.components.target-picker.remove_${this.type}_id` |  | ||||||
|             )} |  | ||||||
|           </ha-tooltip> |  | ||||||
|           <ha-icon-button |  | ||||||
|             class="mdc-chip__icon mdc-chip__icon--trailing" |  | ||||||
|             .label=${this.hass.localize("ui.components.target-picker.remove")} |  | ||||||
|             .path=${mdiClose} |  | ||||||
|             hide-title |  | ||||||
|             .id="remove-${slugify(this.itemId)}" |  | ||||||
|             .type=${this.type} |  | ||||||
|             @click=${this._removeItem} |  | ||||||
|           ></ha-icon-button> |  | ||||||
|         </span> |  | ||||||
|       </div> |  | ||||||
|     `; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private _itemData = memoizeOne((type: TargetType, itemId: string) => { |  | ||||||
|     if (type === "floor") { |  | ||||||
|       const floor = this.hass.floors?.[itemId]; |  | ||||||
|       return { |  | ||||||
|         name: floor?.name || itemId, |  | ||||||
|         iconPath: floor?.icon, |  | ||||||
|         fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome, |  | ||||||
|       }; |  | ||||||
|     } |  | ||||||
|     if (type === "area") { |  | ||||||
|       const area = this.hass.areas?.[itemId]; |  | ||||||
|       return { |  | ||||||
|         name: area?.name || itemId, |  | ||||||
|         iconPath: area?.icon, |  | ||||||
|         fallbackIconPath: mdiTextureBox, |  | ||||||
|       }; |  | ||||||
|     } |  | ||||||
|     if (type === "device") { |  | ||||||
|       const device = this.hass.devices?.[itemId]; |  | ||||||
|  |  | ||||||
|       if (device.primary_config_entry) { |  | ||||||
|         this._getDeviceDomain(device.primary_config_entry); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       return { |  | ||||||
|         name: device ? computeDeviceNameDisplay(device, this.hass) : itemId, |  | ||||||
|         fallbackIconPath: mdiDevices, |  | ||||||
|       }; |  | ||||||
|     } |  | ||||||
|     if (type === "entity") { |  | ||||||
|       this._setDomainName(computeDomain(itemId)); |  | ||||||
|  |  | ||||||
|       const stateObject = this.hass.states[itemId]; |  | ||||||
|       const entityName = computeEntityName( |  | ||||||
|         stateObject, |  | ||||||
|         this.hass.entities, |  | ||||||
|         this.hass.devices |  | ||||||
|       ); |  | ||||||
|       const { device } = getEntityContext( |  | ||||||
|         stateObject, |  | ||||||
|         this.hass.entities, |  | ||||||
|         this.hass.devices, |  | ||||||
|         this.hass.areas, |  | ||||||
|         this.hass.floors |  | ||||||
|       ); |  | ||||||
|       const deviceName = device ? computeDeviceName(device) : undefined; |  | ||||||
|       return { |  | ||||||
|         name: entityName || deviceName || itemId, |  | ||||||
|         stateObject, |  | ||||||
|       }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // type label |  | ||||||
|     const label = this._labelRegistry.find((lab) => lab.label_id === itemId); |  | ||||||
|     let color = label?.color ? computeCssColor(label.color) : undefined; |  | ||||||
|     if (color?.startsWith("var(")) { |  | ||||||
|       const computedStyles = getComputedStyle(this); |  | ||||||
|       color = computedStyles.getPropertyValue( |  | ||||||
|         color.substring(4, color.length - 1) |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|     if (color?.startsWith("#")) { |  | ||||||
|       color = hex2rgb(color).join(","); |  | ||||||
|     } |  | ||||||
|     return { |  | ||||||
|       name: label?.name || itemId, |  | ||||||
|       iconPath: label?.icon, |  | ||||||
|       fallbackIconPath: mdiLabel, |  | ||||||
|       color, |  | ||||||
|     }; |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   private _setDomainName(domain: string) { |  | ||||||
|     this._domainName = domainToName(this.hass.localize, domain); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private async _getDeviceDomain(configEntryId: string) { |  | ||||||
|     try { |  | ||||||
|       const data = await getConfigEntry(this.hass, configEntryId); |  | ||||||
|       const domain = data.config_entry.domain; |  | ||||||
|       this._iconImg = brandsUrl({ |  | ||||||
|         domain: domain, |  | ||||||
|         type: "icon", |  | ||||||
|         darkOptimized: this.hass.themes?.darkMode, |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       this._setDomainName(domain); |  | ||||||
|     } catch { |  | ||||||
|       // failed to load config entry -> ignore |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private _removeItem(ev) { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     fireEvent(this, "remove-target-item", { |  | ||||||
|       type: this.type, |  | ||||||
|       id: this.itemId, |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private _handleExpand(ev) { |  | ||||||
|     ev.stopPropagation(); |  | ||||||
|     fireEvent(this, "expand-target-item", { |  | ||||||
|       type: this.type, |  | ||||||
|       id: this.itemId, |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   static styles = css` |  | ||||||
|     ${unsafeCSS(chipStyles)} |  | ||||||
|     .mdc-chip { |  | ||||||
|       color: var(--primary-text-color); |  | ||||||
|     } |  | ||||||
|     .mdc-chip.add { |  | ||||||
|       color: rgba(0, 0, 0, 0.87); |  | ||||||
|     } |  | ||||||
|     .add-container { |  | ||||||
|       position: relative; |  | ||||||
|       display: inline-flex; |  | ||||||
|     } |  | ||||||
|     .mdc-chip:not(.add) { |  | ||||||
|       cursor: default; |  | ||||||
|     } |  | ||||||
|     .mdc-chip ha-icon-button { |  | ||||||
|       --mdc-icon-button-size: 24px; |  | ||||||
|       display: flex; |  | ||||||
|       align-items: center; |  | ||||||
|       outline: none; |  | ||||||
|     } |  | ||||||
|     .mdc-chip ha-icon-button ha-svg-icon { |  | ||||||
|       border-radius: 50%; |  | ||||||
|       background: var(--secondary-text-color); |  | ||||||
|     } |  | ||||||
|     .mdc-chip__icon.mdc-chip__icon--trailing { |  | ||||||
|       width: var(--ha-space-4); |  | ||||||
|       height: var(--ha-space-4); |  | ||||||
|       --mdc-icon-size: 14px; |  | ||||||
|       color: var(--secondary-text-color); |  | ||||||
|       margin-inline-start: var(--ha-space-1) !important; |  | ||||||
|       margin-inline-end: calc(-1 * var(--ha-space-1)) !important; |  | ||||||
|       direction: var(--direction); |  | ||||||
|     } |  | ||||||
|     .mdc-chip__icon--leading { |  | ||||||
|       display: flex; |  | ||||||
|       align-items: center; |  | ||||||
|       justify-content: center; |  | ||||||
|       --mdc-icon-size: 20px; |  | ||||||
|       border-radius: var(--ha-border-radius-circle); |  | ||||||
|       padding: 6px; |  | ||||||
|       margin-left: -13px !important; |  | ||||||
|       margin-inline-start: -13px !important; |  | ||||||
|       margin-inline-end: var(--ha-space-1) !important; |  | ||||||
|       direction: var(--direction); |  | ||||||
|     } |  | ||||||
|     .expand-btn { |  | ||||||
|       margin-right: var(--ha-space-0); |  | ||||||
|       margin-inline-end: var(--ha-space-0); |  | ||||||
|       margin-inline-start: initial; |  | ||||||
|     } |  | ||||||
|     .mdc-chip.area:not(.add), |  | ||||||
|     .mdc-chip.floor:not(.add) { |  | ||||||
|       border: 1px solid #fed6a4; |  | ||||||
|       background: var(--card-background-color); |  | ||||||
|     } |  | ||||||
|     .mdc-chip.area:not(.add) .mdc-chip__icon--leading, |  | ||||||
|     .mdc-chip.area.add, |  | ||||||
|     .mdc-chip.floor:not(.add) .mdc-chip__icon--leading, |  | ||||||
|     .mdc-chip.floor.add { |  | ||||||
|       background: #fed6a4; |  | ||||||
|     } |  | ||||||
|     .mdc-chip.device:not(.add) { |  | ||||||
|       border: 1px solid #a8e1fb; |  | ||||||
|       background: var(--card-background-color); |  | ||||||
|     } |  | ||||||
|     .mdc-chip.device:not(.add) .mdc-chip__icon--leading, |  | ||||||
|     .mdc-chip.device.add { |  | ||||||
|       background: #a8e1fb; |  | ||||||
|     } |  | ||||||
|     .mdc-chip.entity:not(.add) { |  | ||||||
|       border: 1px solid #d2e7b9; |  | ||||||
|       background: var(--card-background-color); |  | ||||||
|     } |  | ||||||
|     .mdc-chip.entity:not(.add) .mdc-chip__icon--leading, |  | ||||||
|     .mdc-chip.entity.add { |  | ||||||
|       background: #d2e7b9; |  | ||||||
|     } |  | ||||||
|     .mdc-chip.label:not(.add) { |  | ||||||
|       border: 1px solid var(--color, #e0e0e0); |  | ||||||
|       background: var(--card-background-color); |  | ||||||
|     } |  | ||||||
|     .mdc-chip.label:not(.add) .mdc-chip__icon--leading, |  | ||||||
|     .mdc-chip.label.add { |  | ||||||
|       background: var(--background-color, #e0e0e0); |  | ||||||
|     } |  | ||||||
|     .mdc-chip:hover { |  | ||||||
|       z-index: 5; |  | ||||||
|     } |  | ||||||
|     :host([disabled]) .mdc-chip { |  | ||||||
|       opacity: var(--light-disabled-opacity); |  | ||||||
|       pointer-events: none; |  | ||||||
|     } |  | ||||||
|     .tooltip-icon-img { |  | ||||||
|       width: 24px; |  | ||||||
|       height: 24px; |  | ||||||
|     } |  | ||||||
|   `; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| declare global { |  | ||||||
|   interface HTMLElementTagNameMap { |  | ||||||
|     "ha-target-picker-value-chip": HaTargetPickerValueChip; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -6,6 +6,8 @@ import { | |||||||
|   mdiCallSplit, |   mdiCallSplit, | ||||||
|   mdiCodeBraces, |   mdiCodeBraces, | ||||||
|   mdiDevices, |   mdiDevices, | ||||||
|  |   mdiDotsHorizontal, | ||||||
|  |   mdiExcavator, | ||||||
|   mdiFormatListNumbered, |   mdiFormatListNumbered, | ||||||
|   mdiGestureDoubleTap, |   mdiGestureDoubleTap, | ||||||
|   mdiHandBackRight, |   mdiHandBackRight, | ||||||
| @@ -14,10 +16,10 @@ import { | |||||||
|   mdiRoomService, |   mdiRoomService, | ||||||
|   mdiShuffleDisabled, |   mdiShuffleDisabled, | ||||||
|   mdiTimerOutline, |   mdiTimerOutline, | ||||||
|  |   mdiTools, | ||||||
|   mdiTrafficLight, |   mdiTrafficLight, | ||||||
| } from "@mdi/js"; | } from "@mdi/js"; | ||||||
| import type { AutomationElementGroupCollection } from "./automation"; | import type { AutomationElementGroup } from "./automation"; | ||||||
| import type { Action } from "./script"; |  | ||||||
|  |  | ||||||
| export const ACTION_ICONS = { | export const ACTION_ICONS = { | ||||||
|   condition: mdiAbTesting, |   condition: mdiAbTesting, | ||||||
| @@ -46,73 +48,37 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([ | |||||||
|   "variables", |   "variables", | ||||||
| ]); | ]); | ||||||
|  |  | ||||||
| export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [ | export const ACTION_GROUPS: AutomationElementGroup = { | ||||||
|   { |   device_id: {}, | ||||||
|     groups: { |   helpers: { | ||||||
|       device_id: {}, |     icon: mdiTools, | ||||||
|       serviceGroups: {}, |     members: {}, | ||||||
|  |   }, | ||||||
|  |   building_blocks: { | ||||||
|  |     icon: mdiExcavator, | ||||||
|  |     members: { | ||||||
|  |       condition: {}, | ||||||
|  |       delay: {}, | ||||||
|  |       wait_template: {}, | ||||||
|  |       wait_for_trigger: {}, | ||||||
|  |       repeat_count: {}, | ||||||
|  |       repeat_while: {}, | ||||||
|  |       repeat_until: {}, | ||||||
|  |       repeat_for_each: {}, | ||||||
|  |       choose: {}, | ||||||
|  |       if: {}, | ||||||
|  |       stop: {}, | ||||||
|  |       sequence: {}, | ||||||
|  |       parallel: {}, | ||||||
|  |       variables: {}, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   { |   other: { | ||||||
|     titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label", |     icon: mdiDotsHorizontal, | ||||||
|     groups: { |     members: { | ||||||
|       helpers: {}, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   { |  | ||||||
|     titleKey: "ui.panel.config.automation.editor.actions.groups.other.label", |  | ||||||
|     groups: { |  | ||||||
|       event: {}, |       event: {}, | ||||||
|       service: {}, |       service: {}, | ||||||
|       set_conversation_response: {}, |       set_conversation_response: {}, | ||||||
|       other: {}, |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| ] as const; |  | ||||||
|  |  | ||||||
| export const ACTION_BUILDING_BLOCKS_GROUP = { |  | ||||||
|   condition: {}, |  | ||||||
|   delay: {}, |  | ||||||
|   wait_template: {}, |  | ||||||
|   wait_for_trigger: {}, |  | ||||||
|   repeat_count: {}, |  | ||||||
|   repeat_while: {}, |  | ||||||
|   repeat_until: {}, |  | ||||||
|   repeat_for_each: {}, |  | ||||||
|   choose: {}, |  | ||||||
|   if: {}, |  | ||||||
|   stop: {}, |  | ||||||
|   sequence: {}, |  | ||||||
|   parallel: {}, |  | ||||||
|   variables: {}, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| // These will be replaced with the correct action |  | ||||||
| export const VIRTUAL_ACTIONS: Partial< |  | ||||||
|   Record<keyof typeof ACTION_BUILDING_BLOCKS_GROUP, Action> |  | ||||||
| > = { |  | ||||||
|   repeat_count: { |  | ||||||
|     repeat: { |  | ||||||
|       count: 2, |  | ||||||
|       sequence: [], |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   repeat_while: { |  | ||||||
|     repeat: { |  | ||||||
|       while: [], |  | ||||||
|       sequence: [], |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   repeat_until: { |  | ||||||
|     repeat: { |  | ||||||
|       until: [], |  | ||||||
|       sequence: [], |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
|   repeat_for_each: { |  | ||||||
|     repeat: { |  | ||||||
|       for_each: {}, |  | ||||||
|       sequence: [], |  | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
| } as const; | } as const; | ||||||
|   | |||||||
| @@ -1,260 +0,0 @@ | |||||||
| import { computeAreaName } from "../common/entity/compute_area_name"; |  | ||||||
| import { computeDomain } from "../common/entity/compute_domain"; |  | ||||||
| import { computeFloorName } from "../common/entity/compute_floor_name"; |  | ||||||
| import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; |  | ||||||
| import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; |  | ||||||
| import type { HomeAssistant } from "../types"; |  | ||||||
| import type { AreaRegistryEntry } from "./area_registry"; |  | ||||||
| import { |  | ||||||
|   getDeviceEntityDisplayLookup, |  | ||||||
|   type DeviceEntityDisplayLookup, |  | ||||||
|   type DeviceRegistryEntry, |  | ||||||
| } from "./device_registry"; |  | ||||||
| import type { HaEntityPickerEntityFilterFunc } from "./entity"; |  | ||||||
| import type { EntityRegistryDisplayEntry } from "./entity_registry"; |  | ||||||
| import { |  | ||||||
|   floorCompare, |  | ||||||
|   getFloorAreaLookup, |  | ||||||
|   type FloorRegistryEntry, |  | ||||||
| } from "./floor_registry"; |  | ||||||
|  |  | ||||||
| export interface FloorComboBoxItem extends PickerComboBoxItem { |  | ||||||
|   type: "floor" | "area"; |  | ||||||
|   floor?: FloorRegistryEntry; |  | ||||||
|   area?: AreaRegistryEntry; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export interface AreaFloorValue { |  | ||||||
|   id: string; |  | ||||||
|   type: "floor" | "area"; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const getAreasAndFloors = ( |  | ||||||
|   states: HomeAssistant["states"], |  | ||||||
|   haFloors: HomeAssistant["floors"], |  | ||||||
|   haAreas: HomeAssistant["areas"], |  | ||||||
|   haDevices: HomeAssistant["devices"], |  | ||||||
|   haEntities: HomeAssistant["entities"], |  | ||||||
|   formatId: (value: AreaFloorValue) => string, |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   excludeDomains?: string[], |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   deviceFilter?: HaDevicePickerDeviceFilterFunc, |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc, |  | ||||||
|   excludeAreas?: string[], |  | ||||||
|   excludeFloors?: string[] |  | ||||||
| ): FloorComboBoxItem[] => { |  | ||||||
|   const floors = Object.values(haFloors); |  | ||||||
|   const areas = Object.values(haAreas); |  | ||||||
|   const devices = Object.values(haDevices); |  | ||||||
|   const entities = Object.values(haEntities); |  | ||||||
|  |  | ||||||
|   let deviceEntityLookup: DeviceEntityDisplayLookup = {}; |  | ||||||
|   let inputDevices: DeviceRegistryEntry[] | undefined; |  | ||||||
|   let inputEntities: EntityRegistryDisplayEntry[] | undefined; |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     includeDomains || |  | ||||||
|     excludeDomains || |  | ||||||
|     includeDeviceClasses || |  | ||||||
|     deviceFilter || |  | ||||||
|     entityFilter |  | ||||||
|   ) { |  | ||||||
|     deviceEntityLookup = getDeviceEntityDisplayLookup(entities); |  | ||||||
|     inputDevices = devices; |  | ||||||
|     inputEntities = entities.filter((entity) => entity.area_id); |  | ||||||
|  |  | ||||||
|     if (includeDomains) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return deviceEntityLookup[device.id].some((entity) => |  | ||||||
|           includeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter((entity) => |  | ||||||
|         includeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (excludeDomains) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return true; |  | ||||||
|         } |  | ||||||
|         return entities.every( |  | ||||||
|           (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter( |  | ||||||
|         (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (includeDeviceClasses) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return deviceEntityLookup[device.id].some((entity) => { |  | ||||||
|           const stateObj = states[entity.entity_id]; |  | ||||||
|           if (!stateObj) { |  | ||||||
|             return false; |  | ||||||
|           } |  | ||||||
|           return ( |  | ||||||
|             stateObj.attributes.device_class && |  | ||||||
|             includeDeviceClasses.includes(stateObj.attributes.device_class) |  | ||||||
|           ); |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter((entity) => { |  | ||||||
|         const stateObj = states[entity.entity_id]; |  | ||||||
|         return ( |  | ||||||
|           stateObj.attributes.device_class && |  | ||||||
|           includeDeviceClasses.includes(stateObj.attributes.device_class) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (deviceFilter) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => deviceFilter!(device)); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (entityFilter) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return deviceEntityLookup[device.id].some((entity) => { |  | ||||||
|           const stateObj = states[entity.entity_id]; |  | ||||||
|           if (!stateObj) { |  | ||||||
|             return false; |  | ||||||
|           } |  | ||||||
|           return entityFilter(stateObj); |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter((entity) => { |  | ||||||
|         const stateObj = states[entity.entity_id]; |  | ||||||
|         if (!stateObj) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return entityFilter!(stateObj); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   let outputAreas = areas; |  | ||||||
|  |  | ||||||
|   let areaIds: string[] | undefined; |  | ||||||
|  |  | ||||||
|   if (inputDevices) { |  | ||||||
|     areaIds = inputDevices |  | ||||||
|       .filter((device) => device.area_id) |  | ||||||
|       .map((device) => device.area_id!); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (inputEntities) { |  | ||||||
|     areaIds = (areaIds ?? []).concat( |  | ||||||
|       inputEntities |  | ||||||
|         .filter((entity) => entity.area_id) |  | ||||||
|         .map((entity) => entity.area_id!) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (areaIds) { |  | ||||||
|     outputAreas = outputAreas.filter((area) => areaIds!.includes(area.area_id)); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeAreas) { |  | ||||||
|     outputAreas = outputAreas.filter( |  | ||||||
|       (area) => !excludeAreas!.includes(area.area_id) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeFloors) { |  | ||||||
|     outputAreas = outputAreas.filter( |  | ||||||
|       (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const floorAreaLookup = getFloorAreaLookup(outputAreas); |  | ||||||
|   const unassignedAreas = Object.values(outputAreas).filter( |  | ||||||
|     (area) => !area.floor_id || !floorAreaLookup[area.floor_id] |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   const compare = floorCompare(haFloors); |  | ||||||
|  |  | ||||||
|   // @ts-ignore |  | ||||||
|   const floorAreaEntries: [ |  | ||||||
|     FloorRegistryEntry | undefined, |  | ||||||
|     AreaRegistryEntry[], |  | ||||||
|   ][] = Object.entries(floorAreaLookup) |  | ||||||
|     .map(([floorId, floorAreas]) => { |  | ||||||
|       const floor = floors.find((fl) => fl.floor_id === floorId)!; |  | ||||||
|       return [floor, floorAreas] as const; |  | ||||||
|     }) |  | ||||||
|     .sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id)); |  | ||||||
|  |  | ||||||
|   const items: FloorComboBoxItem[] = []; |  | ||||||
|  |  | ||||||
|   floorAreaEntries.forEach(([floor, floorAreas]) => { |  | ||||||
|     if (floor) { |  | ||||||
|       const floorName = computeFloorName(floor); |  | ||||||
|  |  | ||||||
|       const areaSearchLabels = floorAreas |  | ||||||
|         .map((area) => { |  | ||||||
|           const areaName = computeAreaName(area) || area.area_id; |  | ||||||
|           return [area.area_id, areaName, ...area.aliases]; |  | ||||||
|         }) |  | ||||||
|         .flat(); |  | ||||||
|  |  | ||||||
|       items.push({ |  | ||||||
|         id: formatId({ id: floor.floor_id, type: "floor" }), |  | ||||||
|         type: "floor", |  | ||||||
|         primary: floorName, |  | ||||||
|         floor: floor, |  | ||||||
|         icon: floor.icon || undefined, |  | ||||||
|         search_labels: [ |  | ||||||
|           floor.floor_id, |  | ||||||
|           floorName, |  | ||||||
|           ...floor.aliases, |  | ||||||
|           ...areaSearchLabels, |  | ||||||
|         ], |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|     items.push( |  | ||||||
|       ...floorAreas.map((area) => { |  | ||||||
|         const areaName = computeAreaName(area) || area.area_id; |  | ||||||
|         return { |  | ||||||
|           id: formatId({ id: area.area_id, type: "area" }), |  | ||||||
|           type: "area" as const, |  | ||||||
|           primary: areaName, |  | ||||||
|           area: area, |  | ||||||
|           icon: area.icon || undefined, |  | ||||||
|           search_labels: [area.area_id, areaName, ...area.aliases], |  | ||||||
|         }; |  | ||||||
|       }) |  | ||||||
|     ); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   items.push( |  | ||||||
|     ...unassignedAreas.map((area) => { |  | ||||||
|       const areaName = computeAreaName(area) || area.area_id; |  | ||||||
|       return { |  | ||||||
|         id: formatId({ id: area.area_id, type: "area" }), |  | ||||||
|         type: "area" as const, |  | ||||||
|         primary: areaName, |  | ||||||
|         area: area, |  | ||||||
|         icon: area.icon || undefined, |  | ||||||
|         search_labels: [area.area_id, areaName, ...area.aliases], |  | ||||||
|       }; |  | ||||||
|     }) |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   return items; |  | ||||||
| }; |  | ||||||
| @@ -1,10 +1,10 @@ | |||||||
| import type { | import type { | ||||||
|   HassEntityAttributeBase, |   HassEntityAttributeBase, | ||||||
|   HassEntityBase, |   HassEntityBase, | ||||||
|  |   HassServiceTarget, | ||||||
| } from "home-assistant-js-websocket"; | } from "home-assistant-js-websocket"; | ||||||
| import { ensureArray } from "../common/array/ensure-array"; | import { ensureArray } from "../common/array/ensure-array"; | ||||||
| import { navigate } from "../common/navigate"; | import { navigate } from "../common/navigate"; | ||||||
| import type { LocalizeKeys } from "../common/translations/localize"; |  | ||||||
| import { createSearchParam } from "../common/url/search-params"; | import { createSearchParam } from "../common/url/search-params"; | ||||||
| import type { Context, HomeAssistant } from "../types"; | import type { Context, HomeAssistant } from "../types"; | ||||||
| import type { BlueprintInput } from "./blueprint"; | import type { BlueprintInput } from "./blueprint"; | ||||||
| @@ -12,6 +12,7 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition"; | |||||||
| import type { DeviceCondition, DeviceTrigger } from "./device_automation"; | import type { DeviceCondition, DeviceTrigger } from "./device_automation"; | ||||||
| import type { Action, Field, MODES } from "./script"; | import type { Action, Field, MODES } from "./script"; | ||||||
| import { migrateAutomationAction } from "./script"; | import { migrateAutomationAction } from "./script"; | ||||||
|  | import type { TriggerDescription } from "./trigger"; | ||||||
|  |  | ||||||
| export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single"; | export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single"; | ||||||
| export const AUTOMATION_DEFAULT_MAX = 10; | export const AUTOMATION_DEFAULT_MAX = 10; | ||||||
| @@ -85,6 +86,11 @@ export interface BaseTrigger { | |||||||
|   id?: string; |   id?: string; | ||||||
|   variables?: Record<string, unknown>; |   variables?: Record<string, unknown>; | ||||||
|   enabled?: boolean; |   enabled?: boolean; | ||||||
|  |   options?: Record<string, unknown>; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface PlatformTrigger extends BaseTrigger { | ||||||
|  |   target?: HassServiceTarget; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface StateTrigger extends BaseTrigger { | export interface StateTrigger extends BaseTrigger { | ||||||
| @@ -294,11 +300,6 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition { | |||||||
|   not: Condition[]; |   not: Condition[]; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface AutomationElementGroupCollection { |  | ||||||
|   titleKey?: LocalizeKeys; |  | ||||||
|   groups: AutomationElementGroup; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export type AutomationElementGroup = Record< | export type AutomationElementGroup = Record< | ||||||
|   string, |   string, | ||||||
|   { icon?: string; members?: AutomationElementGroup } |   { icon?: string; members?: AutomationElementGroup } | ||||||
| @@ -576,6 +577,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig { | |||||||
|   insertAfter: (value: Trigger | Trigger[]) => boolean; |   insertAfter: (value: Trigger | Trigger[]) => boolean; | ||||||
|   toggleYamlMode: () => void; |   toggleYamlMode: () => void; | ||||||
|   config: Trigger; |   config: Trigger; | ||||||
|  |   description?: TriggerDescription; | ||||||
|   yamlMode: boolean; |   yamlMode: boolean; | ||||||
|   uiSupported: boolean; |   uiSupported: boolean; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -803,9 +803,15 @@ const tryDescribeTrigger = ( | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   const triggerType = trigger.trigger; | ||||||
|  |   const [domain, type] = triggerType.split(".", 2); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     hass.localize( |     hass.localize( | ||||||
|       `ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label` |       `component.${domain}.triggers.${type || "_"}.description_configured` | ||||||
|  |     ) || | ||||||
|  |     hass.localize( | ||||||
|  |       `ui.panel.config.automation.editor.triggers.type.${triggerType}.label` | ||||||
|     ) || |     ) || | ||||||
|     hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`) |     hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`) | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -3,6 +3,8 @@ import { | |||||||
|   mdiClockOutline, |   mdiClockOutline, | ||||||
|   mdiCodeBraces, |   mdiCodeBraces, | ||||||
|   mdiDevices, |   mdiDevices, | ||||||
|  |   mdiDotsHorizontal, | ||||||
|  |   mdiExcavator, | ||||||
|   mdiGateOr, |   mdiGateOr, | ||||||
|   mdiIdentifier, |   mdiIdentifier, | ||||||
|   mdiMapClock, |   mdiMapClock, | ||||||
| @@ -13,7 +15,7 @@ import { | |||||||
|   mdiStateMachine, |   mdiStateMachine, | ||||||
|   mdiWeatherSunny, |   mdiWeatherSunny, | ||||||
| } from "@mdi/js"; | } from "@mdi/js"; | ||||||
| import type { AutomationElementGroupCollection } from "./automation"; | import type { AutomationElementGroup } from "./automation"; | ||||||
|  |  | ||||||
| export const CONDITION_ICONS = { | export const CONDITION_ICONS = { | ||||||
|   device: mdiDevices, |   device: mdiDevices, | ||||||
| @@ -29,31 +31,25 @@ export const CONDITION_ICONS = { | |||||||
|   zone: mdiMapMarkerRadius, |   zone: mdiMapMarkerRadius, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [ | export const CONDITION_GROUPS: AutomationElementGroup = { | ||||||
|   { |   device: {}, | ||||||
|     groups: { |   entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, | ||||||
|       device: {}, |   time_location: { | ||||||
|       entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, |     icon: mdiMapClock, | ||||||
|       time_location: { |     members: { sun: {}, time: {}, zone: {} }, | ||||||
|         icon: mdiMapClock, |  | ||||||
|         members: { sun: {}, time: {}, zone: {} }, |  | ||||||
|       }, |  | ||||||
|     }, |  | ||||||
|   }, |   }, | ||||||
|   { |   building_blocks: { | ||||||
|     titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label", |     icon: mdiExcavator, | ||||||
|     groups: { |     members: { and: {}, or: {}, not: {} }, | ||||||
|  |   }, | ||||||
|  |   other: { | ||||||
|  |     icon: mdiDotsHorizontal, | ||||||
|  |     members: { | ||||||
|       template: {}, |       template: {}, | ||||||
|       trigger: {}, |       trigger: {}, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
| ] as const; | } as const; | ||||||
|  |  | ||||||
| export const CONDITION_BUILDING_BLOCKS_GROUP = { |  | ||||||
|   and: {}, |  | ||||||
|   or: {}, |  | ||||||
|   not: {}, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"]; | export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -79,7 +79,6 @@ export interface DataEntryFlowStepAbort { | |||||||
|   reason: string; |   reason: string; | ||||||
|   description_placeholders?: Record<string, string>; |   description_placeholders?: Record<string, string>; | ||||||
|   translation_domain?: string; |   translation_domain?: string; | ||||||
|   next_flow?: [FlowType, string]; // [flow_type, flow_id] |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface DataEntryFlowStepProgress { | export interface DataEntryFlowStepProgress { | ||||||
|   | |||||||
| @@ -1,20 +1,12 @@ | |||||||
| import { computeAreaName } from "../common/entity/compute_area_name"; |  | ||||||
| import { computeDeviceNameDisplay } from "../common/entity/compute_device_name"; |  | ||||||
| import { computeDomain } from "../common/entity/compute_domain"; |  | ||||||
| import { computeStateName } from "../common/entity/compute_state_name"; | import { computeStateName } from "../common/entity/compute_state_name"; | ||||||
| import { getDeviceContext } from "../common/entity/context/get_device_context"; |  | ||||||
| import { caseInsensitiveStringCompare } from "../common/string/compare"; | import { caseInsensitiveStringCompare } from "../common/string/compare"; | ||||||
| import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; |  | ||||||
| import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; |  | ||||||
| import type { HomeAssistant } from "../types"; | import type { HomeAssistant } from "../types"; | ||||||
| import type { ConfigEntry } from "./config_entries"; | import type { ConfigEntry } from "./config_entries"; | ||||||
| import type { HaEntityPickerEntityFilterFunc } from "./entity"; |  | ||||||
| import type { | import type { | ||||||
|   EntityRegistryDisplayEntry, |   EntityRegistryDisplayEntry, | ||||||
|   EntityRegistryEntry, |   EntityRegistryEntry, | ||||||
| } from "./entity_registry"; | } from "./entity_registry"; | ||||||
| import type { EntitySources } from "./entity_sources"; | import type { EntitySources } from "./entity_sources"; | ||||||
| import { domainToName } from "./integration"; |  | ||||||
| import type { RegistryEntry } from "./registry"; | import type { RegistryEntry } from "./registry"; | ||||||
|  |  | ||||||
| export { | export { | ||||||
| @@ -171,147 +163,3 @@ export const getDeviceIntegrationLookup = ( | |||||||
|   } |   } | ||||||
|   return deviceIntegrations; |   return deviceIntegrations; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export interface DevicePickerItem extends PickerComboBoxItem { |  | ||||||
|   domain?: string; |  | ||||||
|   domain_name?: string; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const getDevices = ( |  | ||||||
|   hass: HomeAssistant, |  | ||||||
|   configEntryLookup: Record<string, ConfigEntry>, |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   excludeDomains?: string[], |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   deviceFilter?: HaDevicePickerDeviceFilterFunc, |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc, |  | ||||||
|   excludeDevices?: string[], |  | ||||||
|   value?: string |  | ||||||
| ): DevicePickerItem[] => { |  | ||||||
|   const devices = Object.values(hass.devices); |  | ||||||
|   const entities = Object.values(hass.entities); |  | ||||||
|  |  | ||||||
|   let deviceEntityLookup: DeviceEntityDisplayLookup = {}; |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     includeDomains || |  | ||||||
|     excludeDomains || |  | ||||||
|     includeDeviceClasses || |  | ||||||
|     entityFilter |  | ||||||
|   ) { |  | ||||||
|     deviceEntityLookup = getDeviceEntityDisplayLookup(entities); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   let inputDevices = devices.filter( |  | ||||||
|     (device) => device.id === value || !device.disabled_by |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   if (includeDomains) { |  | ||||||
|     inputDevices = inputDevices.filter((device) => { |  | ||||||
|       const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|       if (!devEntities || !devEntities.length) { |  | ||||||
|         return false; |  | ||||||
|       } |  | ||||||
|       return deviceEntityLookup[device.id].some((entity) => |  | ||||||
|         includeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|       ); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeDomains) { |  | ||||||
|     inputDevices = inputDevices.filter((device) => { |  | ||||||
|       const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|       if (!devEntities || !devEntities.length) { |  | ||||||
|         return true; |  | ||||||
|       } |  | ||||||
|       return entities.every( |  | ||||||
|         (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|       ); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeDevices) { |  | ||||||
|     inputDevices = inputDevices.filter( |  | ||||||
|       (device) => !excludeDevices!.includes(device.id) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (includeDeviceClasses) { |  | ||||||
|     inputDevices = inputDevices.filter((device) => { |  | ||||||
|       const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|       if (!devEntities || !devEntities.length) { |  | ||||||
|         return false; |  | ||||||
|       } |  | ||||||
|       return deviceEntityLookup[device.id].some((entity) => { |  | ||||||
|         const stateObj = hass.states[entity.entity_id]; |  | ||||||
|         if (!stateObj) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return ( |  | ||||||
|           stateObj.attributes.device_class && |  | ||||||
|           includeDeviceClasses.includes(stateObj.attributes.device_class) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (entityFilter) { |  | ||||||
|     inputDevices = inputDevices.filter((device) => { |  | ||||||
|       const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|       if (!devEntities || !devEntities.length) { |  | ||||||
|         return false; |  | ||||||
|       } |  | ||||||
|       return devEntities.some((entity) => { |  | ||||||
|         const stateObj = hass.states[entity.entity_id]; |  | ||||||
|         if (!stateObj) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return entityFilter(stateObj); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (deviceFilter) { |  | ||||||
|     inputDevices = inputDevices.filter( |  | ||||||
|       (device) => |  | ||||||
|         // We always want to include the device of the current value |  | ||||||
|         device.id === value || deviceFilter!(device) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const outputDevices = inputDevices.map<DevicePickerItem>((device) => { |  | ||||||
|     const deviceName = computeDeviceNameDisplay( |  | ||||||
|       device, |  | ||||||
|       hass, |  | ||||||
|       deviceEntityLookup[device.id] |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     const { area } = getDeviceContext(device, hass); |  | ||||||
|  |  | ||||||
|     const areaName = area ? computeAreaName(area) : undefined; |  | ||||||
|  |  | ||||||
|     const configEntry = device.primary_config_entry |  | ||||||
|       ? configEntryLookup?.[device.primary_config_entry] |  | ||||||
|       : undefined; |  | ||||||
|  |  | ||||||
|     const domain = configEntry?.domain; |  | ||||||
|     const domainName = domain ? domainToName(hass.localize, domain) : undefined; |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       id: device.id, |  | ||||||
|       label: "", |  | ||||||
|       primary: |  | ||||||
|         deviceName || |  | ||||||
|         hass.localize("ui.components.device-picker.unnamed_device"), |  | ||||||
|       secondary: areaName, |  | ||||||
|       domain: configEntry?.domain, |  | ||||||
|       domain_name: domainName, |  | ||||||
|       search_labels: [deviceName, areaName, domain, domainName].filter( |  | ||||||
|         Boolean |  | ||||||
|       ) as string[], |  | ||||||
|       sorting_label: deviceName || "zzz", |  | ||||||
|     }; |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   return outputDevices; |  | ||||||
| }; |  | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| import type { HassEntity } from "home-assistant-js-websocket"; |  | ||||||
| import { arrayLiteralIncludes } from "../common/array/literal-includes"; | import { arrayLiteralIncludes } from "../common/array/literal-includes"; | ||||||
|  |  | ||||||
| export const UNAVAILABLE = "unavailable"; | export const UNAVAILABLE = "unavailable"; | ||||||
| @@ -11,5 +10,3 @@ export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const; | |||||||
|  |  | ||||||
| export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES); | export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES); | ||||||
| export const isOffState = arrayLiteralIncludes(OFF_STATES); | export const isOffState = arrayLiteralIncludes(OFF_STATES); | ||||||
|  |  | ||||||
| export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; |  | ||||||
|   | |||||||
| @@ -1,17 +1,12 @@ | |||||||
| import type { Connection, HassEntity } from "home-assistant-js-websocket"; | import type { Connection } from "home-assistant-js-websocket"; | ||||||
| import { createCollection } from "home-assistant-js-websocket"; | import { createCollection } from "home-assistant-js-websocket"; | ||||||
| import type { Store } from "home-assistant-js-websocket/dist/store"; | import type { Store } from "home-assistant-js-websocket/dist/store"; | ||||||
| import memoizeOne from "memoize-one"; | import memoizeOne from "memoize-one"; | ||||||
| import { computeDomain } from "../common/entity/compute_domain"; | import { computeDomain } from "../common/entity/compute_domain"; | ||||||
| import { computeEntityNameList } from "../common/entity/compute_entity_name_display"; |  | ||||||
| import { computeStateName } from "../common/entity/compute_state_name"; | import { computeStateName } from "../common/entity/compute_state_name"; | ||||||
| import { caseInsensitiveStringCompare } from "../common/string/compare"; | import { caseInsensitiveStringCompare } from "../common/string/compare"; | ||||||
| import { computeRTL } from "../common/util/compute_rtl"; |  | ||||||
| import { debounce } from "../common/util/debounce"; | import { debounce } from "../common/util/debounce"; | ||||||
| import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; |  | ||||||
| import type { HomeAssistant } from "../types"; | import type { HomeAssistant } from "../types"; | ||||||
| import type { HaEntityPickerEntityFilterFunc } from "./entity"; |  | ||||||
| import { domainToName } from "./integration"; |  | ||||||
| import type { LightColor } from "./light"; | import type { LightColor } from "./light"; | ||||||
| import type { RegistryEntry } from "./registry"; | import type { RegistryEntry } from "./registry"; | ||||||
|  |  | ||||||
| @@ -329,122 +324,3 @@ export const getAutomaticEntityIds = ( | |||||||
|     type: "config/entity_registry/get_automatic_entity_ids", |     type: "config/entity_registry/get_automatic_entity_ids", | ||||||
|     entity_ids, |     entity_ids, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| export interface EntityComboBoxItem extends PickerComboBoxItem { |  | ||||||
|   domain_name?: string; |  | ||||||
|   stateObj?: HassEntity; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const getEntities = ( |  | ||||||
|   hass: HomeAssistant, |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   excludeDomains?: string[], |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc, |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   includeUnitOfMeasurement?: string[], |  | ||||||
|   includeEntities?: string[], |  | ||||||
|   excludeEntities?: string[], |  | ||||||
|   value?: string |  | ||||||
| ): EntityComboBoxItem[] => { |  | ||||||
|   let items: EntityComboBoxItem[] = []; |  | ||||||
|  |  | ||||||
|   let entityIds = Object.keys(hass.states); |  | ||||||
|  |  | ||||||
|   if (includeEntities) { |  | ||||||
|     entityIds = entityIds.filter((entityId) => |  | ||||||
|       includeEntities.includes(entityId) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeEntities) { |  | ||||||
|     entityIds = entityIds.filter( |  | ||||||
|       (entityId) => !excludeEntities.includes(entityId) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (includeDomains) { |  | ||||||
|     entityIds = entityIds.filter((eid) => |  | ||||||
|       includeDomains.includes(computeDomain(eid)) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeDomains) { |  | ||||||
|     entityIds = entityIds.filter( |  | ||||||
|       (eid) => !excludeDomains.includes(computeDomain(eid)) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   items = entityIds.map<EntityComboBoxItem>((entityId) => { |  | ||||||
|     const stateObj = hass.states[entityId]; |  | ||||||
|  |  | ||||||
|     const friendlyName = computeStateName(stateObj); // Keep this for search |  | ||||||
|     const [entityName, deviceName, areaName] = computeEntityNameList( |  | ||||||
|       stateObj, |  | ||||||
|       [{ type: "entity" }, { type: "device" }, { type: "area" }], |  | ||||||
|       hass.entities, |  | ||||||
|       hass.devices, |  | ||||||
|       hass.areas, |  | ||||||
|       hass.floors |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     const domainName = domainToName(hass.localize, computeDomain(entityId)); |  | ||||||
|  |  | ||||||
|     const isRTL = computeRTL(hass); |  | ||||||
|  |  | ||||||
|     const primary = entityName || deviceName || entityId; |  | ||||||
|     const secondary = [areaName, entityName ? deviceName : undefined] |  | ||||||
|       .filter(Boolean) |  | ||||||
|       .join(isRTL ? " ◂ " : " ▸ "); |  | ||||||
|     const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|       id: entityId, |  | ||||||
|       primary: primary, |  | ||||||
|       secondary: secondary, |  | ||||||
|       domain_name: domainName, |  | ||||||
|       sorting_label: [deviceName, entityName].filter(Boolean).join("_"), |  | ||||||
|       search_labels: [ |  | ||||||
|         entityName, |  | ||||||
|         deviceName, |  | ||||||
|         areaName, |  | ||||||
|         domainName, |  | ||||||
|         friendlyName, |  | ||||||
|         entityId, |  | ||||||
|       ].filter(Boolean) as string[], |  | ||||||
|       a11y_label: a11yLabel, |  | ||||||
|       stateObj: stateObj, |  | ||||||
|     }; |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   if (includeDeviceClasses) { |  | ||||||
|     items = items.filter( |  | ||||||
|       (item) => |  | ||||||
|         // We always want to include the entity of the current value |  | ||||||
|         item.id === value || |  | ||||||
|         (item.stateObj?.attributes.device_class && |  | ||||||
|           includeDeviceClasses.includes(item.stateObj.attributes.device_class)) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (includeUnitOfMeasurement) { |  | ||||||
|     items = items.filter( |  | ||||||
|       (item) => |  | ||||||
|         // We always want to include the entity of the current value |  | ||||||
|         item.id === value || |  | ||||||
|         (item.stateObj?.attributes.unit_of_measurement && |  | ||||||
|           includeUnitOfMeasurement.includes( |  | ||||||
|             item.stateObj.attributes.unit_of_measurement |  | ||||||
|           )) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (entityFilter) { |  | ||||||
|     items = items.filter( |  | ||||||
|       (item) => |  | ||||||
|         // We always want to include the entity of the current value |  | ||||||
|         item.id === value || (item.stateObj && entityFilter!(item.stateObj)) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return items; |  | ||||||
| }; |  | ||||||
|   | |||||||
| @@ -68,18 +68,13 @@ export const getFloorAreaLookup = ( | |||||||
| }; | }; | ||||||
|  |  | ||||||
| export const floorCompare = | export const floorCompare = | ||||||
|   (entries?: HomeAssistant["floors"], order?: string[]) => |   (entries?: FloorRegistryEntry[], order?: string[]) => | ||||||
|   (a: string, b: string) => { |   (a: string, b: string) => { | ||||||
|     const indexA = order ? order.indexOf(a) : -1; |     const indexA = order ? order.indexOf(a) : -1; | ||||||
|     const indexB = order ? order.indexOf(b) : -1; |     const indexB = order ? order.indexOf(b) : -1; | ||||||
|     if (indexA === -1 && indexB === -1) { |     if (indexA === -1 && indexB === -1) { | ||||||
|       const floorA = entries?.[a]; |       const nameA = entries?.[a]?.name ?? a; | ||||||
|       const floorB = entries?.[b]; |       const nameB = entries?.[b]?.name ?? b; | ||||||
|       if (floorA && floorB && floorA.level !== floorB.level) { |  | ||||||
|         return (floorB.level ?? -9999) - (floorA.level ?? -9999); |  | ||||||
|       } |  | ||||||
|       const nameA = floorA?.name ?? a; |  | ||||||
|       const nameB = floorB?.name ?? b; |  | ||||||
|       return stringCompare(nameA, nameB); |       return stringCompare(nameA, nameB); | ||||||
|     } |     } | ||||||
|     if (indexA === -1) { |     if (indexA === -1) { | ||||||
|   | |||||||
| @@ -131,14 +131,19 @@ const resources: { | |||||||
|     all?: Promise<Record<string, ServiceIcons>>; |     all?: Promise<Record<string, ServiceIcons>>; | ||||||
|     domains: Record<string, ServiceIcons | Promise<ServiceIcons>>; |     domains: Record<string, ServiceIcons | Promise<ServiceIcons>>; | ||||||
|   }; |   }; | ||||||
|  |   triggers: { | ||||||
|  |     all?: Promise<Record<string, TriggerIcons>>; | ||||||
|  |     domains: Record<string, TriggerIcons | Promise<TriggerIcons>>; | ||||||
|  |   }; | ||||||
| } = { | } = { | ||||||
|   entity: {}, |   entity: {}, | ||||||
|   entity_component: {}, |   entity_component: {}, | ||||||
|   services: { domains: {} }, |   services: { domains: {} }, | ||||||
|  |   triggers: { domains: {} }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| interface IconResources< | interface IconResources< | ||||||
|   T extends ComponentIcons | PlatformIcons | ServiceIcons, |   T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons, | ||||||
| > { | > { | ||||||
|   resources: Record<string, T>; |   resources: Record<string, T>; | ||||||
| } | } | ||||||
| @@ -182,12 +187,22 @@ type ServiceIcons = Record< | |||||||
|   { service: string; sections?: Record<string, string> } |   { service: string; sections?: Record<string, string> } | ||||||
| >; | >; | ||||||
|  |  | ||||||
| export type IconCategory = "entity" | "entity_component" | "services"; | type TriggerIcons = Record< | ||||||
|  |   string, | ||||||
|  |   { trigger: string; sections?: Record<string, string> } | ||||||
|  | >; | ||||||
|  |  | ||||||
|  | export type IconCategory = | ||||||
|  |   | "entity" | ||||||
|  |   | "entity_component" | ||||||
|  |   | "services" | ||||||
|  |   | "triggers"; | ||||||
|  |  | ||||||
| interface CategoryType { | interface CategoryType { | ||||||
|   entity: PlatformIcons; |   entity: PlatformIcons; | ||||||
|   entity_component: ComponentIcons; |   entity_component: ComponentIcons; | ||||||
|   services: ServiceIcons; |   services: ServiceIcons; | ||||||
|  |   triggers: TriggerIcons; | ||||||
| } | } | ||||||
|  |  | ||||||
| export const getHassIcons = async <T extends IconCategory>( | export const getHassIcons = async <T extends IconCategory>( | ||||||
| @@ -265,12 +280,10 @@ export const getServiceIcons = async ( | |||||||
|     if (!force && resources.services.all) { |     if (!force && resources.services.all) { | ||||||
|       return resources.services.all; |       return resources.services.all; | ||||||
|     } |     } | ||||||
|     resources.services.all = getHassIcons(hass, "services", domain).then( |     resources.services.all = getHassIcons(hass, "services").then((res) => { | ||||||
|       (res) => { |       resources.services.domains = res.resources; | ||||||
|         resources.services.domains = res.resources; |       return res?.resources; | ||||||
|         return res?.resources; |     }); | ||||||
|       } |  | ||||||
|     ); |  | ||||||
|     return resources.services.all; |     return resources.services.all; | ||||||
|   } |   } | ||||||
|   if (!force && domain in resources.services.domains) { |   if (!force && domain in resources.services.domains) { | ||||||
| @@ -292,6 +305,40 @@ export const getServiceIcons = async ( | |||||||
|   return resources.services.domains[domain]; |   return resources.services.domains[domain]; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const getTriggerIcons = async ( | ||||||
|  |   hass: HomeAssistant, | ||||||
|  |   domain?: string, | ||||||
|  |   force = false | ||||||
|  | ): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> => { | ||||||
|  |   if (!domain) { | ||||||
|  |     if (!force && resources.triggers.all) { | ||||||
|  |       return resources.triggers.all; | ||||||
|  |     } | ||||||
|  |     resources.triggers.all = getHassIcons(hass, "triggers").then((res) => { | ||||||
|  |       resources.triggers.domains = res.resources; | ||||||
|  |       return res?.resources; | ||||||
|  |     }); | ||||||
|  |     return resources.triggers.all; | ||||||
|  |   } | ||||||
|  |   if (!force && domain in resources.triggers.domains) { | ||||||
|  |     return resources.triggers.domains[domain]; | ||||||
|  |   } | ||||||
|  |   if (resources.triggers.all && !force) { | ||||||
|  |     await resources.triggers.all; | ||||||
|  |     if (domain in resources.triggers.domains) { | ||||||
|  |       return resources.triggers.domains[domain]; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   if (!isComponentLoaded(hass, domain)) { | ||||||
|  |     return undefined; | ||||||
|  |   } | ||||||
|  |   const result = getHassIcons(hass, "triggers", domain); | ||||||
|  |   resources.triggers.domains[domain] = result.then( | ||||||
|  |     (res) => res?.resources[domain] | ||||||
|  |   ); | ||||||
|  |   return resources.triggers.domains[domain]; | ||||||
|  | }; | ||||||
|  |  | ||||||
| // Cache for sorted range keys | // Cache for sorted range keys | ||||||
| const sortedRangeCache = new WeakMap<Record<string, string>, number[]>(); | const sortedRangeCache = new WeakMap<Record<string, string>, number[]>(); | ||||||
|  |  | ||||||
| @@ -471,6 +518,26 @@ export const attributeIcon = async ( | |||||||
|   return icon; |   return icon; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const triggerIcon = async ( | ||||||
|  |   hass: HomeAssistant, | ||||||
|  |   trigger: string | ||||||
|  | ): Promise<string | undefined> => { | ||||||
|  |   let icon: string | undefined; | ||||||
|  |  | ||||||
|  |   const domain = trigger.includes(".") ? computeDomain(trigger) : trigger; | ||||||
|  |   const triggerName = trigger.includes(".") ? computeObjectId(trigger) : "_"; | ||||||
|  |  | ||||||
|  |   const triggerIcons = await getTriggerIcons(hass, domain); | ||||||
|  |   if (triggerIcons) { | ||||||
|  |     const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string]; | ||||||
|  |     icon = trgrIcon?.trigger; | ||||||
|  |   } | ||||||
|  |   if (!icon) { | ||||||
|  |     icon = await domainIcon(hass, domain); | ||||||
|  |   } | ||||||
|  |   return icon; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const serviceIcon = async ( | export const serviceIcon = async ( | ||||||
|   hass: HomeAssistant, |   hass: HomeAssistant, | ||||||
|   service: string |   service: string | ||||||
|   | |||||||
| @@ -1,20 +1,9 @@ | |||||||
| import { mdiLabel } from "@mdi/js"; |  | ||||||
| import type { Connection } from "home-assistant-js-websocket"; | import type { Connection } from "home-assistant-js-websocket"; | ||||||
| import { createCollection } from "home-assistant-js-websocket"; | import { createCollection } from "home-assistant-js-websocket"; | ||||||
| import type { Store } from "home-assistant-js-websocket/dist/store"; | import type { Store } from "home-assistant-js-websocket/dist/store"; | ||||||
| import { computeDomain } from "../common/entity/compute_domain"; |  | ||||||
| import { stringCompare } from "../common/string/compare"; | import { stringCompare } from "../common/string/compare"; | ||||||
| import { debounce } from "../common/util/debounce"; | import { debounce } from "../common/util/debounce"; | ||||||
| import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; |  | ||||||
| import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; |  | ||||||
| import type { HomeAssistant } from "../types"; | import type { HomeAssistant } from "../types"; | ||||||
| import { |  | ||||||
|   getDeviceEntityDisplayLookup, |  | ||||||
|   type DeviceEntityDisplayLookup, |  | ||||||
|   type DeviceRegistryEntry, |  | ||||||
| } from "./device_registry"; |  | ||||||
| import type { HaEntityPickerEntityFilterFunc } from "./entity"; |  | ||||||
| import type { EntityRegistryDisplayEntry } from "./entity_registry"; |  | ||||||
| import type { RegistryEntry } from "./registry"; | import type { RegistryEntry } from "./registry"; | ||||||
|  |  | ||||||
| export interface LabelRegistryEntry extends RegistryEntry { | export interface LabelRegistryEntry extends RegistryEntry { | ||||||
| @@ -99,178 +88,3 @@ export const deleteLabelRegistryEntry = ( | |||||||
|     type: "config/label_registry/delete", |     type: "config/label_registry/delete", | ||||||
|     label_id: labelId, |     label_id: labelId, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| export const getLabels = ( |  | ||||||
|   hass: HomeAssistant, |  | ||||||
|   labels?: LabelRegistryEntry[], |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   excludeDomains?: string[], |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   deviceFilter?: HaDevicePickerDeviceFilterFunc, |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc, |  | ||||||
|   excludeLabels?: string[] |  | ||||||
| ): PickerComboBoxItem[] => { |  | ||||||
|   if (!labels || labels.length === 0) { |  | ||||||
|     return []; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const devices = Object.values(hass.devices); |  | ||||||
|   const entities = Object.values(hass.entities); |  | ||||||
|  |  | ||||||
|   let deviceEntityLookup: DeviceEntityDisplayLookup = {}; |  | ||||||
|   let inputDevices: DeviceRegistryEntry[] | undefined; |  | ||||||
|   let inputEntities: EntityRegistryDisplayEntry[] | undefined; |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     includeDomains || |  | ||||||
|     excludeDomains || |  | ||||||
|     includeDeviceClasses || |  | ||||||
|     deviceFilter || |  | ||||||
|     entityFilter |  | ||||||
|   ) { |  | ||||||
|     deviceEntityLookup = getDeviceEntityDisplayLookup(entities); |  | ||||||
|     inputDevices = devices; |  | ||||||
|     inputEntities = entities.filter((entity) => entity.labels.length > 0); |  | ||||||
|  |  | ||||||
|     if (includeDomains) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return deviceEntityLookup[device.id].some((entity) => |  | ||||||
|           includeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter((entity) => |  | ||||||
|         includeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (excludeDomains) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return true; |  | ||||||
|         } |  | ||||||
|         return entities.every( |  | ||||||
|           (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter( |  | ||||||
|         (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (includeDeviceClasses) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return deviceEntityLookup[device.id].some((entity) => { |  | ||||||
|           const stateObj = hass.states[entity.entity_id]; |  | ||||||
|           if (!stateObj) { |  | ||||||
|             return false; |  | ||||||
|           } |  | ||||||
|           return ( |  | ||||||
|             stateObj.attributes.device_class && |  | ||||||
|             includeDeviceClasses.includes(stateObj.attributes.device_class) |  | ||||||
|           ); |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter((entity) => { |  | ||||||
|         const stateObj = hass.states[entity.entity_id]; |  | ||||||
|         return ( |  | ||||||
|           stateObj.attributes.device_class && |  | ||||||
|           includeDeviceClasses.includes(stateObj.attributes.device_class) |  | ||||||
|         ); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (deviceFilter) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => deviceFilter!(device)); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (entityFilter) { |  | ||||||
|       inputDevices = inputDevices!.filter((device) => { |  | ||||||
|         const devEntities = deviceEntityLookup[device.id]; |  | ||||||
|         if (!devEntities || !devEntities.length) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return deviceEntityLookup[device.id].some((entity) => { |  | ||||||
|           const stateObj = hass.states[entity.entity_id]; |  | ||||||
|           if (!stateObj) { |  | ||||||
|             return false; |  | ||||||
|           } |  | ||||||
|           return entityFilter(stateObj); |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|       inputEntities = inputEntities!.filter((entity) => { |  | ||||||
|         const stateObj = hass.states[entity.entity_id]; |  | ||||||
|         if (!stateObj) { |  | ||||||
|           return false; |  | ||||||
|         } |  | ||||||
|         return entityFilter!(stateObj); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   let outputLabels = labels; |  | ||||||
|   const usedLabels = new Set<string>(); |  | ||||||
|  |  | ||||||
|   let areaIds: string[] | undefined; |  | ||||||
|  |  | ||||||
|   if (inputDevices) { |  | ||||||
|     areaIds = inputDevices |  | ||||||
|       .filter((device) => device.area_id) |  | ||||||
|       .map((device) => device.area_id!); |  | ||||||
|  |  | ||||||
|     inputDevices.forEach((device) => { |  | ||||||
|       device.labels.forEach((label) => usedLabels.add(label)); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (inputEntities) { |  | ||||||
|     areaIds = (areaIds ?? []).concat( |  | ||||||
|       inputEntities |  | ||||||
|         .filter((entity) => entity.area_id) |  | ||||||
|         .map((entity) => entity.area_id!) |  | ||||||
|     ); |  | ||||||
|     inputEntities.forEach((entity) => { |  | ||||||
|       entity.labels.forEach((label) => usedLabels.add(label)); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (areaIds) { |  | ||||||
|     areaIds.forEach((areaId) => { |  | ||||||
|       const area = hass.areas[areaId]; |  | ||||||
|       area.labels.forEach((label) => usedLabels.add(label)); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (excludeLabels) { |  | ||||||
|     outputLabels = outputLabels.filter( |  | ||||||
|       (label) => !excludeLabels!.includes(label.label_id) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (inputDevices || inputEntities) { |  | ||||||
|     outputLabels = outputLabels.filter((label) => |  | ||||||
|       usedLabels.has(label.label_id) |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const items = outputLabels.map<PickerComboBoxItem>((label) => ({ |  | ||||||
|     id: label.label_id, |  | ||||||
|     primary: label.name, |  | ||||||
|     icon: label.icon || undefined, |  | ||||||
|     icon_path: label.icon ? undefined : mdiLabel, |  | ||||||
|     sorting_label: label.name, |  | ||||||
|     search_labels: [label.name, label.label_id, label.description].filter( |  | ||||||
|       (v): v is string => Boolean(v) |  | ||||||
|     ), |  | ||||||
|   })); |  | ||||||
|  |  | ||||||
|   return items; |  | ||||||
| }; |  | ||||||
|   | |||||||
| @@ -352,7 +352,6 @@ export interface NumberSelector { | |||||||
| interface ObjectSelectorField { | interface ObjectSelectorField { | ||||||
|   selector: Selector; |   selector: Selector; | ||||||
|   label?: string; |   label?: string; | ||||||
|   description?: string; |  | ||||||
|   required?: boolean; |   required?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,164 +0,0 @@ | |||||||
| import type { HassServiceTarget } from "home-assistant-js-websocket"; |  | ||||||
| import { computeDomain } from "../common/entity/compute_domain"; |  | ||||||
| import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; |  | ||||||
| import type { HomeAssistant } from "../types"; |  | ||||||
| import type { AreaRegistryEntry } from "./area_registry"; |  | ||||||
| import type { DeviceRegistryEntry } from "./device_registry"; |  | ||||||
| import type { HaEntityPickerEntityFilterFunc } from "./entity"; |  | ||||||
| import type { EntityRegistryDisplayEntry } from "./entity_registry"; |  | ||||||
|  |  | ||||||
| export type TargetType = "entity" | "device" | "area" | "label" | "floor"; |  | ||||||
| export type TargetTypeFloorless = Exclude<TargetType, "floor">; |  | ||||||
|  |  | ||||||
| export interface ExtractFromTargetResult { |  | ||||||
|   missing_areas: string[]; |  | ||||||
|   missing_devices: string[]; |  | ||||||
|   missing_floors: string[]; |  | ||||||
|   missing_labels: string[]; |  | ||||||
|   referenced_areas: string[]; |  | ||||||
|   referenced_devices: string[]; |  | ||||||
|   referenced_entities: string[]; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export interface ExtractFromTargetResultReferenced { |  | ||||||
|   referenced_areas: string[]; |  | ||||||
|   referenced_devices: string[]; |  | ||||||
|   referenced_entities: string[]; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const extractFromTarget = async ( |  | ||||||
|   hass: HomeAssistant, |  | ||||||
|   target: HassServiceTarget |  | ||||||
| ) => |  | ||||||
|   hass.callWS<ExtractFromTargetResult>({ |  | ||||||
|     type: "extract_from_target", |  | ||||||
|     target, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
| export const areaMeetsFilter = ( |  | ||||||
|   area: AreaRegistryEntry, |  | ||||||
|   devices: HomeAssistant["devices"], |  | ||||||
|   entities: HomeAssistant["entities"], |  | ||||||
|   deviceFilter?: HaDevicePickerDeviceFilterFunc, |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   states?: HomeAssistant["states"], |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc |  | ||||||
| ): boolean => { |  | ||||||
|   const areaDevices = Object.values(devices).filter( |  | ||||||
|     (device) => device.area_id === area.area_id |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     areaDevices.some((device) => |  | ||||||
|       deviceMeetsFilter( |  | ||||||
|         device, |  | ||||||
|         entities, |  | ||||||
|         deviceFilter, |  | ||||||
|         includeDomains, |  | ||||||
|         includeDeviceClasses, |  | ||||||
|         states, |  | ||||||
|         entityFilter |  | ||||||
|       ) |  | ||||||
|     ) |  | ||||||
|   ) { |  | ||||||
|     return true; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const areaEntities = Object.values(entities).filter( |  | ||||||
|     (entity) => entity.area_id === area.area_id |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     areaEntities.some((entity) => |  | ||||||
|       entityRegMeetsFilter( |  | ||||||
|         entity, |  | ||||||
|         false, |  | ||||||
|         includeDomains, |  | ||||||
|         includeDeviceClasses, |  | ||||||
|         states, |  | ||||||
|         entityFilter |  | ||||||
|       ) |  | ||||||
|     ) |  | ||||||
|   ) { |  | ||||||
|     return true; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return false; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const deviceMeetsFilter = ( |  | ||||||
|   device: DeviceRegistryEntry, |  | ||||||
|   entities: HomeAssistant["entities"], |  | ||||||
|   deviceFilter?: HaDevicePickerDeviceFilterFunc, |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   states?: HomeAssistant["states"], |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc |  | ||||||
| ): boolean => { |  | ||||||
|   const devEntities = Object.values(entities).filter( |  | ||||||
|     (entity) => entity.device_id === device.id |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     !devEntities.some((entity) => |  | ||||||
|       entityRegMeetsFilter( |  | ||||||
|         entity, |  | ||||||
|         false, |  | ||||||
|         includeDomains, |  | ||||||
|         includeDeviceClasses, |  | ||||||
|         states, |  | ||||||
|         entityFilter |  | ||||||
|       ) |  | ||||||
|     ) |  | ||||||
|   ) { |  | ||||||
|     return false; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (deviceFilter) { |  | ||||||
|     return deviceFilter(device); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return true; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const entityRegMeetsFilter = ( |  | ||||||
|   entity: EntityRegistryDisplayEntry, |  | ||||||
|   includeSecondary = false, |  | ||||||
|   includeDomains?: string[], |  | ||||||
|   includeDeviceClasses?: string[], |  | ||||||
|   states?: HomeAssistant["states"], |  | ||||||
|   entityFilter?: HaEntityPickerEntityFilterFunc |  | ||||||
| ): boolean => { |  | ||||||
|   if (entity.hidden || (entity.entity_category && !includeSecondary)) { |  | ||||||
|     return false; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if ( |  | ||||||
|     includeDomains && |  | ||||||
|     !includeDomains.includes(computeDomain(entity.entity_id)) |  | ||||||
|   ) { |  | ||||||
|     return false; |  | ||||||
|   } |  | ||||||
|   if (includeDeviceClasses) { |  | ||||||
|     const stateObj = states?.[entity.entity_id]; |  | ||||||
|     if (!stateObj) { |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
|     if ( |  | ||||||
|       !stateObj.attributes.device_class || |  | ||||||
|       !includeDeviceClasses!.includes(stateObj.attributes.device_class) |  | ||||||
|     ) { |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (entityFilter) { |  | ||||||
|     const stateObj = states?.[entity.entity_id]; |  | ||||||
|     if (!stateObj) { |  | ||||||
|       return false; |  | ||||||
|     } |  | ||||||
|     return entityFilter!(stateObj); |  | ||||||
|   } |  | ||||||
|   return true; |  | ||||||
| }; |  | ||||||
| @@ -73,7 +73,8 @@ export type TranslationCategory = | |||||||
|   | "application_credentials" |   | "application_credentials" | ||||||
|   | "issues" |   | "issues" | ||||||
|   | "selector" |   | "selector" | ||||||
|   | "services"; |   | "services" | ||||||
|  |   | "triggers"; | ||||||
|  |  | ||||||
| export const subscribeTranslationPreferences = ( | export const subscribeTranslationPreferences = ( | ||||||
|   hass: HomeAssistant, |   hass: HomeAssistant, | ||||||
|   | |||||||
| @@ -1,73 +1,23 @@ | |||||||
| import { | import { mdiDotsHorizontal, mdiMapClock, mdiShape } from "@mdi/js"; | ||||||
|   mdiAvTimer, |  | ||||||
|   mdiCalendar, |  | ||||||
|   mdiClockOutline, |  | ||||||
|   mdiCodeBraces, |  | ||||||
|   mdiDevices, |  | ||||||
|   mdiFormatListBulleted, |  | ||||||
|   mdiGestureDoubleTap, |  | ||||||
|   mdiMapClock, |  | ||||||
|   mdiMapMarker, |  | ||||||
|   mdiMapMarkerRadius, |  | ||||||
|   mdiMessageAlert, |  | ||||||
|   mdiMicrophoneMessage, |  | ||||||
|   mdiNfcVariant, |  | ||||||
|   mdiNumeric, |  | ||||||
|   mdiShape, |  | ||||||
|   mdiStateMachine, |  | ||||||
|   mdiSwapHorizontal, |  | ||||||
|   mdiWeatherSunny, |  | ||||||
|   mdiWebhook, |  | ||||||
| } from "@mdi/js"; |  | ||||||
|  |  | ||||||
| import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; | import type { HomeAssistant } from "../types"; | ||||||
| import type { | import type { | ||||||
|   AutomationElementGroupCollection, |   AutomationElementGroup, | ||||||
|   Trigger, |   Trigger, | ||||||
|   TriggerList, |   TriggerList, | ||||||
| } from "./automation"; | } from "./automation"; | ||||||
|  | import type { Selector, TargetSelector } from "./selector"; | ||||||
|  |  | ||||||
| export const TRIGGER_ICONS = { | export const TRIGGER_GROUPS: AutomationElementGroup = { | ||||||
|   calendar: mdiCalendar, |   device: {}, | ||||||
|   device: mdiDevices, |   entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, | ||||||
|   event: mdiGestureDoubleTap, |   time_location: { | ||||||
|   state: mdiStateMachine, |     icon: mdiMapClock, | ||||||
|   geo_location: mdiMapMarker, |     members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} }, | ||||||
|   homeassistant: mdiHomeAssistant, |  | ||||||
|   mqtt: mdiSwapHorizontal, |  | ||||||
|   numeric_state: mdiNumeric, |  | ||||||
|   sun: mdiWeatherSunny, |  | ||||||
|   conversation: mdiMicrophoneMessage, |  | ||||||
|   tag: mdiNfcVariant, |  | ||||||
|   template: mdiCodeBraces, |  | ||||||
|   time: mdiClockOutline, |  | ||||||
|   time_pattern: mdiAvTimer, |  | ||||||
|   webhook: mdiWebhook, |  | ||||||
|   persistent_notification: mdiMessageAlert, |  | ||||||
|   zone: mdiMapMarkerRadius, |  | ||||||
|   list: mdiFormatListBulleted, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [ |  | ||||||
|   { |  | ||||||
|     groups: { |  | ||||||
|       device: {}, |  | ||||||
|       entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, |  | ||||||
|       time_location: { |  | ||||||
|         icon: mdiMapClock, |  | ||||||
|         members: { |  | ||||||
|           calendar: {}, |  | ||||||
|           sun: {}, |  | ||||||
|           time: {}, |  | ||||||
|           time_pattern: {}, |  | ||||||
|           zone: {}, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     }, |  | ||||||
|   }, |   }, | ||||||
|   { |   other: { | ||||||
|     titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label", |     icon: mdiDotsHorizontal, | ||||||
|     groups: { |     members: { | ||||||
|       event: {}, |       event: {}, | ||||||
|       geo_location: {}, |       geo_location: {}, | ||||||
|       homeassistant: {}, |       homeassistant: {}, | ||||||
| @@ -79,7 +29,30 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [ | |||||||
|       persistent_notification: {}, |       persistent_notification: {}, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
| ] as const; | } as const; | ||||||
|  |  | ||||||
| export const isTriggerList = (trigger: Trigger): trigger is TriggerList => | export const isTriggerList = (trigger: Trigger): trigger is TriggerList => | ||||||
|   "triggers" in trigger; |   "triggers" in trigger; | ||||||
|  |  | ||||||
|  | export interface TriggerDescription { | ||||||
|  |   target?: TargetSelector["target"]; | ||||||
|  |   fields: Record< | ||||||
|  |     string, | ||||||
|  |     { | ||||||
|  |       example?: string | boolean | number; | ||||||
|  |       default?: unknown; | ||||||
|  |       required?: boolean; | ||||||
|  |       selector?: Selector; | ||||||
|  |     } | ||||||
|  |   >; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type TriggerDescriptions = Record<string, TriggerDescription>; | ||||||
|  |  | ||||||
|  | export const subscribeTriggers = ( | ||||||
|  |   hass: HomeAssistant, | ||||||
|  |   callback: (triggers: TriggerDescriptions) => void | ||||||
|  | ) => | ||||||
|  |   hass.connection.subscribeMessage<TriggerDescriptions>(callback, { | ||||||
|  |     type: "trigger_platforms/subscribe", | ||||||
|  |   }); | ||||||
|   | |||||||
| @@ -1,13 +1,18 @@ | |||||||
| import type { Connection } from "home-assistant-js-websocket"; | import type { Connection } from "home-assistant-js-websocket"; | ||||||
| import { createCollection } from "home-assistant-js-websocket"; | import { createCollection } from "home-assistant-js-websocket"; | ||||||
| import type { Store } from "home-assistant-js-websocket/dist/store"; | import type { Store } from "home-assistant-js-websocket/dist/store"; | ||||||
|  | import { stringCompare } from "../common/string/compare"; | ||||||
| import { debounce } from "../common/util/debounce"; | import { debounce } from "../common/util/debounce"; | ||||||
| import type { AreaRegistryEntry } from "./area_registry"; | import type { AreaRegistryEntry } from "./area_registry"; | ||||||
|  |  | ||||||
| const fetchAreaRegistry = (conn: Connection) => | const fetchAreaRegistry = (conn: Connection) => | ||||||
|   conn.sendMessagePromise<AreaRegistryEntry[]>({ |   conn | ||||||
|     type: "config/area_registry/list", |     .sendMessagePromise<AreaRegistryEntry[]>({ | ||||||
|   }); |       type: "config/area_registry/list", | ||||||
|  |     }) | ||||||
|  |     .then((areas) => | ||||||
|  |       areas.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name)) | ||||||
|  |     ); | ||||||
|  |  | ||||||
| const subscribeAreaRegistryUpdates = ( | const subscribeAreaRegistryUpdates = ( | ||||||
|   conn: Connection, |   conn: Connection, | ||||||
|   | |||||||
| @@ -1,13 +1,23 @@ | |||||||
| import type { Connection } from "home-assistant-js-websocket"; | import type { Connection } from "home-assistant-js-websocket"; | ||||||
| import { createCollection } from "home-assistant-js-websocket"; | import { createCollection } from "home-assistant-js-websocket"; | ||||||
| import type { Store } from "home-assistant-js-websocket/dist/store"; | import type { Store } from "home-assistant-js-websocket/dist/store"; | ||||||
|  | import { stringCompare } from "../common/string/compare"; | ||||||
| import { debounce } from "../common/util/debounce"; | import { debounce } from "../common/util/debounce"; | ||||||
| import type { FloorRegistryEntry } from "./floor_registry"; | import type { FloorRegistryEntry } from "./floor_registry"; | ||||||
|  |  | ||||||
| const fetchFloorRegistry = (conn: Connection) => | const fetchFloorRegistry = (conn: Connection) => | ||||||
|   conn.sendMessagePromise<FloorRegistryEntry[]>({ |   conn | ||||||
|     type: "config/floor_registry/list", |     .sendMessagePromise({ | ||||||
|   }); |       type: "config/floor_registry/list", | ||||||
|  |     }) | ||||||
|  |     .then((floors) => | ||||||
|  |       (floors as FloorRegistryEntry[]).sort((ent1, ent2) => { | ||||||
|  |         if (ent1.level !== ent2.level) { | ||||||
|  |           return (ent1.level ?? 9999) - (ent2.level ?? 9999); | ||||||
|  |         } | ||||||
|  |         return stringCompare(ent1.name, ent2.name); | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  |  | ||||||
| const subscribeFloorRegistryUpdates = ( | const subscribeFloorRegistryUpdates = ( | ||||||
|   conn: Connection, |   conn: Connection, | ||||||
|   | |||||||
| @@ -472,10 +472,7 @@ class DataEntryFlowDialog extends LitElement { | |||||||
|     this._step = undefined; |     this._step = undefined; | ||||||
|     await this.updateComplete; |     await this.updateComplete; | ||||||
|     this._step = _step; |     this._step = _step; | ||||||
|     if ( |     if (_step.type === "create_entry" && _step.next_flow) { | ||||||
|       (_step.type === "create_entry" || _step.type === "abort") && |  | ||||||
|       _step.next_flow |  | ||||||
|     ) { |  | ||||||
|       // skip device rename if there is a chained flow |       // skip device rename if there is a chained flow | ||||||
|       this._step = undefined; |       this._step = undefined; | ||||||
|       this._handler = undefined; |       this._handler = undefined; | ||||||
| @@ -489,36 +486,32 @@ class DataEntryFlowDialog extends LitElement { | |||||||
|           carryOverDevices: this._devices( |           carryOverDevices: this._devices( | ||||||
|             this._params!.flowConfig.showDevices, |             this._params!.flowConfig.showDevices, | ||||||
|             Object.values(this.hass.devices), |             Object.values(this.hass.devices), | ||||||
|             _step.type === "create_entry" ? _step.result?.entry_id : undefined, |             _step.result?.entry_id, | ||||||
|             this._params!.carryOverDevices |             this._params!.carryOverDevices | ||||||
|           ).map((device) => device.id), |           ).map((device) => device.id), | ||||||
|           dialogClosedCallback: this._params!.dialogClosedCallback, |           dialogClosedCallback: this._params!.dialogClosedCallback, | ||||||
|         }); |         }); | ||||||
|       } else if (_step.next_flow[0] === "options_flow") { |       } else if (_step.next_flow[0] === "options_flow") { | ||||||
|         if (_step.type === "create_entry") { |         showOptionsFlowDialog( | ||||||
|           showOptionsFlowDialog( |           this._params!.dialogParentElement!, | ||||||
|             this._params!.dialogParentElement!, |           _step.result!, | ||||||
|             _step.result!, |           { | ||||||
|             { |             continueFlowId: _step.next_flow[1], | ||||||
|               continueFlowId: _step.next_flow[1], |             navigateToResult: this._params!.navigateToResult, | ||||||
|               navigateToResult: this._params!.navigateToResult, |             dialogClosedCallback: this._params!.dialogClosedCallback, | ||||||
|               dialogClosedCallback: this._params!.dialogClosedCallback, |           } | ||||||
|             } |         ); | ||||||
|           ); |  | ||||||
|         } |  | ||||||
|       } else if (_step.next_flow[0] === "config_subentries_flow") { |       } else if (_step.next_flow[0] === "config_subentries_flow") { | ||||||
|         if (_step.type === "create_entry") { |         showSubConfigFlowDialog( | ||||||
|           showSubConfigFlowDialog( |           this._params!.dialogParentElement!, | ||||||
|             this._params!.dialogParentElement!, |           _step.result!, | ||||||
|             _step.result!, |           _step.next_flow[0], | ||||||
|             _step.next_flow[0], |           { | ||||||
|             { |             continueFlowId: _step.next_flow[1], | ||||||
|               continueFlowId: _step.next_flow[1], |             navigateToResult: this._params!.navigateToResult, | ||||||
|               navigateToResult: this._params!.navigateToResult, |             dialogClosedCallback: this._params!.dialogClosedCallback, | ||||||
|               dialogClosedCallback: this._params!.dialogClosedCallback, |           } | ||||||
|             } |         ); | ||||||
|           ); |  | ||||||
|         } |  | ||||||
|       } else { |       } else { | ||||||
|         this.closeDialog(); |         this.closeDialog(); | ||||||
|         showAlertDialog(this._params!.dialogParentElement!, { |         showAlertDialog(this._params!.dialogParentElement!, { | ||||||
|   | |||||||
| @@ -212,7 +212,6 @@ export class DialogEnterCode | |||||||
|       grid-gap: var(--ha-space-6); |       grid-gap: var(--ha-space-6); | ||||||
|       justify-items: center; |       justify-items: center; | ||||||
|       align-items: center; |       align-items: center; | ||||||
|       direction: ltr; |  | ||||||
|     } |     } | ||||||
|     .clear { |     .clear { | ||||||
|       grid-row-start: 4; |       grid-row-start: 4; | ||||||
|   | |||||||
| @@ -77,80 +77,84 @@ class MoreInfoMediaPlayer extends LitElement { | |||||||
|       return nothing; |       return nothing; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (!stateActive(this.stateObj)) { | ||||||
|  |       return nothing; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const supportsMute = supportsFeature( |     const supportsMute = supportsFeature( | ||||||
|       this.stateObj, |       this.stateObj, | ||||||
|       MediaPlayerEntityFeature.VOLUME_MUTE |       MediaPlayerEntityFeature.VOLUME_MUTE | ||||||
|     ); |     ); | ||||||
|     const supportsSliding = supportsFeature( |     const supportsSet = supportsFeature( | ||||||
|       this.stateObj, |       this.stateObj, | ||||||
|       MediaPlayerEntityFeature.VOLUME_SET |       MediaPlayerEntityFeature.VOLUME_SET | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     return html`${(supportsFeature( |     const supportsStep = supportsFeature( | ||||||
|       this.stateObj!, |       this.stateObj, | ||||||
|       MediaPlayerEntityFeature.VOLUME_SET |       MediaPlayerEntityFeature.VOLUME_STEP | ||||||
|     ) || |     ); | ||||||
|       supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) && |  | ||||||
|     stateActive(this.stateObj!) |     if (!supportsMute && !supportsSet && !supportsStep) { | ||||||
|       ? html` |       return nothing; | ||||||
|           <div class="volume"> |     } | ||||||
|             ${supportsMute |  | ||||||
|               ? html` |     return html` | ||||||
|                   <ha-icon-button |       <div class="volume"> | ||||||
|                     .path=${this.stateObj.attributes.is_volume_muted |         ${supportsMute | ||||||
|                       ? mdiVolumeOff |           ? html` | ||||||
|                       : mdiVolumeHigh} |               <ha-icon-button | ||||||
|                     .label=${this.hass.localize( |                 .path=${this.stateObj.attributes.is_volume_muted | ||||||
|                       `ui.card.media_player.${ |                   ? mdiVolumeOff | ||||||
|                         this.stateObj.attributes.is_volume_muted |                   : mdiVolumeHigh} | ||||||
|                           ? "media_volume_unmute" |                 .label=${this.hass.localize( | ||||||
|                           : "media_volume_mute" |                   `ui.card.media_player.${ | ||||||
|                       }` |                     this.stateObj.attributes.is_volume_muted | ||||||
|                     )} |                       ? "media_volume_unmute" | ||||||
|                     @click=${this._toggleMute} |                       : "media_volume_mute" | ||||||
|                   ></ha-icon-button> |                   }` | ||||||
|                 ` |                 )} | ||||||
|               : ""} |                 @click=${this._toggleMute} | ||||||
|             ${supportsFeature( |               ></ha-icon-button> | ||||||
|               this.stateObj, |             ` | ||||||
|               MediaPlayerEntityFeature.VOLUME_STEP |           : nothing} | ||||||
|             ) && !supportsSliding |         ${supportsStep | ||||||
|               ? html` |           ? html` <ha-icon-button | ||||||
|                   <ha-icon-button |               action="volume_down" | ||||||
|                     action="volume_down" |               .path=${mdiVolumeMinus} | ||||||
|                     .path=${mdiVolumeMinus} |               .label=${this.hass.localize( | ||||||
|                     .label=${this.hass.localize( |                 "ui.card.media_player.media_volume_down" | ||||||
|                       "ui.card.media_player.media_volume_down" |               )} | ||||||
|                     )} |               @click=${this._handleClick} | ||||||
|                     @click=${this._handleClick} |             ></ha-icon-button>` | ||||||
|                   ></ha-icon-button> |           : nothing} | ||||||
|                   <ha-icon-button |         ${supportsSet | ||||||
|                     action="volume_up" |           ? html` | ||||||
|                     .path=${mdiVolumePlus} |               ${!supportsMute && !supportsStep | ||||||
|                     .label=${this.hass.localize( |                 ? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>` | ||||||
|                       "ui.card.media_player.media_volume_up" |                 : nothing} | ||||||
|                     )} |               <ha-slider | ||||||
|                     @click=${this._handleClick} |                 labeled | ||||||
|                   ></ha-icon-button> |                 id="input" | ||||||
|                 ` |                 .value=${Number(this.stateObj.attributes.volume_level) * 100} | ||||||
|               : nothing} |                 @change=${this._selectedValueChanged} | ||||||
|             ${supportsSliding |               ></ha-slider> | ||||||
|               ? html` |             ` | ||||||
|                   ${!supportsMute |           : nothing} | ||||||
|                     ? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>` |         ${supportsStep | ||||||
|                     : nothing} |           ? html` | ||||||
|                   <ha-slider |               <ha-icon-button | ||||||
|                     labeled |                 action="volume_up" | ||||||
|                     id="input" |                 .path=${mdiVolumePlus} | ||||||
|                     .value=${Number(this.stateObj.attributes.volume_level) * |                 .label=${this.hass.localize( | ||||||
|                     100} |                   "ui.card.media_player.media_volume_up" | ||||||
|                     @change=${this._selectedValueChanged} |                 )} | ||||||
|                   ></ha-slider> |                 @click=${this._handleClick} | ||||||
|                 ` |               ></ha-icon-button> | ||||||
|               : nothing} |             ` | ||||||
|           </div> |           : nothing} | ||||||
|         ` |       </div> | ||||||
|       : nothing}`; |     `; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   protected _renderSourceControl() { |   protected _renderSourceControl() { | ||||||
|   | |||||||
| @@ -1,10 +1,14 @@ | |||||||
|  | import { mdiClose } from "@mdi/js"; | ||||||
| import type { UnsubscribeFunc } from "home-assistant-js-websocket"; | import type { UnsubscribeFunc } from "home-assistant-js-websocket"; | ||||||
| import type { CSSResultGroup } from "lit"; | import type { CSSResultGroup } from "lit"; | ||||||
| import { LitElement, css, html } from "lit"; | import { LitElement, css, html, nothing } from "lit"; | ||||||
| import { customElement, property, state } from "lit/decorators"; | import { customElement, property, query, state } from "lit/decorators"; | ||||||
| import { fireEvent } from "../../common/dom/fire_event"; | import { fireEvent } from "../../common/dom/fire_event"; | ||||||
| import "../../components/ha-alert"; | import "../../components/ha-alert"; | ||||||
| import "../../components/ha-wa-dialog"; | import "../../components/ha-dialog-header"; | ||||||
|  | import "../../components/ha-icon-button"; | ||||||
|  | import "../../components/ha-md-dialog"; | ||||||
|  | import type { HaMdDialog } from "../../components/ha-md-dialog"; | ||||||
| import "../../components/ha-spinner"; | import "../../components/ha-spinner"; | ||||||
| import { | import { | ||||||
|   subscribeBackupEvents, |   subscribeBackupEvents, | ||||||
| @@ -33,6 +37,8 @@ class DialogRestartWait extends LitElement { | |||||||
|  |  | ||||||
|   private _backupEventsSubscription?: Promise<UnsubscribeFunc>; |   private _backupEventsSubscription?: Promise<UnsubscribeFunc>; | ||||||
|  |  | ||||||
|  |   @query("ha-md-dialog") private _dialog?: HaMdDialog; | ||||||
|  |  | ||||||
|   public async showDialog(params: RestartWaitDialogParams): Promise<void> { |   public async showDialog(params: RestartWaitDialogParams): Promise<void> { | ||||||
|     this._open = true; |     this._open = true; | ||||||
|     this._loadBackupState(); |     this._loadBackupState(); | ||||||
| @@ -43,11 +49,9 @@ class DialogRestartWait extends LitElement { | |||||||
|     this._actionOnIdle = params.action; |     this._actionOnIdle = params.action; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public closeDialog(): void { |  | ||||||
|     this._open = false; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   private _dialogClosed(): void { |   private _dialogClosed(): void { | ||||||
|  |     this._open = false; | ||||||
|  |  | ||||||
|     if (this._backupEventsSubscription) { |     if (this._backupEventsSubscription) { | ||||||
|       this._backupEventsSubscription.then((unsub) => { |       this._backupEventsSubscription.then((unsub) => { | ||||||
|         unsub(); |         unsub(); | ||||||
| @@ -58,6 +62,10 @@ class DialogRestartWait extends LitElement { | |||||||
|     fireEvent(this, "dialog-closed", { dialog: this.localName }); |     fireEvent(this, "dialog-closed", { dialog: this.localName }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public closeDialog(): void { | ||||||
|  |     this._dialog?.close(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   private _getWaitMessage() { |   private _getWaitMessage() { | ||||||
|     switch (this._backupState) { |     switch (this._backupState) { | ||||||
|       case "create_backup": |       case "create_backup": | ||||||
| @@ -72,17 +80,28 @@ class DialogRestartWait extends LitElement { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   protected render() { |   protected render() { | ||||||
|  |     if (!this._open) { | ||||||
|  |       return nothing; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const waitMessage = this._getWaitMessage(); |     const waitMessage = this._getWaitMessage(); | ||||||
|  |  | ||||||
|     return html` |     return html` | ||||||
|       <ha-wa-dialog |       <ha-md-dialog | ||||||
|         .hass=${this.hass} |         open | ||||||
|         .open=${this._open} |  | ||||||
|         .headerTitle=${this._title} |  | ||||||
|         width="medium" |  | ||||||
|         @closed=${this._dialogClosed} |         @closed=${this._dialogClosed} | ||||||
|  |         .disableCancelAction=${true} | ||||||
|       > |       > | ||||||
|         <div class="content"> |         <ha-dialog-header slot="headline"> | ||||||
|  |           <ha-icon-button | ||||||
|  |             slot="navigationIcon" | ||||||
|  |             .label=${this.hass.localize("ui.common.cancel")} | ||||||
|  |             .path=${mdiClose} | ||||||
|  |             @click=${this.closeDialog} | ||||||
|  |           ></ha-icon-button> | ||||||
|  |           <span slot="title" .title=${this._title}> ${this._title} </span> | ||||||
|  |         </ha-dialog-header> | ||||||
|  |         <div slot="content" class="content"> | ||||||
|           ${this._error |           ${this._error | ||||||
|             ? html`<ha-alert alert-type="error" |             ? html`<ha-alert alert-type="error" | ||||||
|                 >${this.hass.localize("ui.dialogs.restart.error_backup_state", { |                 >${this.hass.localize("ui.dialogs.restart.error_backup_state", { | ||||||
| @@ -94,7 +113,7 @@ class DialogRestartWait extends LitElement { | |||||||
|                 ${waitMessage} |                 ${waitMessage} | ||||||
|               `} |               `} | ||||||
|         </div> |         </div> | ||||||
|       </ha-wa-dialog> |       </ha-md-dialog> | ||||||
|     `; |     `; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -120,9 +139,15 @@ class DialogRestartWait extends LitElement { | |||||||
|       haStyle, |       haStyle, | ||||||
|       haStyleDialog, |       haStyleDialog, | ||||||
|       css` |       css` | ||||||
|         ha-wa-dialog { |         ha-md-dialog { | ||||||
|           --dialog-content-padding: 0; |           --dialog-content-padding: 0; | ||||||
|         } |         } | ||||||
|  |         @media all and (min-width: 550px) { | ||||||
|  |           ha-md-dialog { | ||||||
|  |             min-width: 500px; | ||||||
|  |             max-width: 500px; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|         .content { |         .content { | ||||||
|           display: flex; |           display: flex; | ||||||
|           flex-direction: column; |           flex-direction: column; | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ import { classMap } from "lit/directives/class-map"; | |||||||
| import { fireEvent } from "../common/dom/fire_event"; | import { fireEvent } from "../common/dom/fire_event"; | ||||||
| import type { LocalizeFunc } from "../common/translations/localize"; | import type { LocalizeFunc } from "../common/translations/localize"; | ||||||
| import "../components/chips/ha-assist-chip"; | import "../components/chips/ha-assist-chip"; | ||||||
|  | import "../components/chips/ha-filter-chip"; | ||||||
| import "../components/data-table/ha-data-table"; | import "../components/data-table/ha-data-table"; | ||||||
| import type { | import type { | ||||||
|   DataTableColumnContainer, |   DataTableColumnContainer, | ||||||
|   | |||||||
| @@ -33,7 +33,7 @@ const COMPONENTS = { | |||||||
|   "media-browser": () => |   "media-browser": () => | ||||||
|     import("../panels/media-browser/ha-panel-media-browser"), |     import("../panels/media-browser/ha-panel-media-browser"), | ||||||
|   light: () => import("../panels/light/ha-panel-light"), |   light: () => import("../panels/light/ha-panel-light"), | ||||||
|   safety: () => import("../panels/safety/ha-panel-safety"), |   security: () => import("../panels/security/ha-panel-security"), | ||||||
|   climate: () => import("../panels/climate/ha-panel-climate"), |   climate: () => import("../panels/climate/ha-panel-climate"), | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user