mirror of
				https://github.com/home-assistant/frontend.git
				synced 2025-11-04 00:19:47 +00:00 
			
		
		
		
	Compare commits
	
		
			170 Commits
		
	
	
		
			loading-an
			...
			fix-assist
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					79488030fc | ||
| 
						 | 
					cf2fbc1747 | ||
| 
						 | 
					c2b37c514d | ||
| 
						 | 
					e49feeb4aa | ||
| 
						 | 
					a0c30e433a | ||
| 
						 | 
					354ce027eb | ||
| 
						 | 
					5c224a942d | ||
| 
						 | 
					0efa4f81d4 | ||
| 
						 | 
					3ad2f35f29 | ||
| 
						 | 
					7a21d5f7bc | ||
| 
						 | 
					33226587e6 | ||
| 
						 | 
					bd2673f311 | ||
| 
						 | 
					cecadde497 | ||
| 
						 | 
					494b8811d0 | ||
| 
						 | 
					4e0a49b3da | ||
| 
						 | 
					3145fed5dc | ||
| 
						 | 
					3dd040fdc7 | ||
| 
						 | 
					e3abe9736c | ||
| 
						 | 
					fe41e72774 | ||
| 
						 | 
					7078ef52d4 | ||
| 
						 | 
					f1c9802ee3 | ||
| 
						 | 
					35697e3f94 | ||
| 
						 | 
					8ea7ad3026 | ||
| 
						 | 
					73747fbedc | ||
| 
						 | 
					aaa92bd354 | ||
| 
						 | 
					5f75fc5bcb | ||
| 
						 | 
					5fa44548c3 | ||
| 
						 | 
					1945c11621 | ||
| 
						 | 
					930575d292 | ||
| 
						 | 
					0147dbab00 | ||
| 
						 | 
					13ace24b83 | ||
| 
						 | 
					616333591a | ||
| 
						 | 
					8f5875c30f | ||
| 
						 | 
					517cd49f35 | ||
| 
						 | 
					25d9fc94b2 | ||
| 
						 | 
					7b188759e3 | ||
| 
						 | 
					76772d1098 | ||
| 
						 | 
					6052745ca0 | ||
| 
						 | 
					89b9780345 | ||
| 
						 | 
					a607edca96 | ||
| 
						 | 
					52eb3d8063 | ||
| 
						 | 
					1361fc36bf | ||
| 
						 | 
					505ef2bd11 | ||
| 
						 | 
					c0cc66c1ab | ||
| 
						 | 
					7cfbc521c7 | ||
| 
						 | 
					e064ce56cc | ||
| 
						 | 
					8d688aa3a9 | ||
| 
						 | 
					d122483449 | ||
| 
						 | 
					f17bbc3f79 | ||
| 
						 | 
					c88f8fcce0 | ||
| 
						 | 
					8efabde916 | ||
| 
						 | 
					e821e1ec83 | ||
| 
						 | 
					dc7516da94 | ||
| 
						 | 
					a545a377a7 | ||
| 
						 | 
					3634dbcbbf | ||
| 
						 | 
					75af4f939e | ||
| 
						 | 
					453a2ac7f3 | ||
| 
						 | 
					8fbd0226fc | ||
| 
						 | 
					2a8d935601 | ||
| 
						 | 
					a6328fb6d7 | ||
| 
						 | 
					a78b61006f | ||
| 
						 | 
					d506aa23b6 | ||
| 
						 | 
					48b4df43ab | ||
| 
						 | 
					8cdcd9cb55 | ||
| 
						 | 
					a1e2ac1d99 | ||
| 
						 | 
					8ecddbc42c | ||
| 
						 | 
					6f70ef52a5 | ||
| 
						 | 
					7dff02d7c8 | ||
| 
						 | 
					8bbd7a6a06 | ||
| 
						 | 
					5c73a06f76 | ||
| 
						 | 
					9943dae82c | ||
| 
						 | 
					70bf049df0 | ||
| 
						 | 
					f9d9fbb7f0 | ||
| 
						 | 
					9cb84d3f37 | ||
| 
						 | 
					c1bcf27cf8 | ||
| 
						 | 
					164ec2a9b5 | ||
| 
						 | 
					20001a551c | ||
| 
						 | 
					b7f85bf733 | ||
| 
						 | 
					b303e9441b | ||
| 
						 | 
					8f4bd0f620 | ||
| 
						 | 
					596346bf59 | ||
| 
						 | 
					769cea92aa | ||
| 
						 | 
					f825016514 | ||
| 
						 | 
					c6fd45bd6a | ||
| 
						 | 
					6c4f4af75c | ||
| 
						 | 
					cd5c3ef2f6 | ||
| 
						 | 
					636a6fa02e | ||
| 
						 | 
					21b83426d6 | ||
| 
						 | 
					c139ec22f9 | ||
| 
						 | 
					a6ef3a26da | ||
| 
						 | 
					221ca56121 | ||
| 
						 | 
					4e6e3629a8 | ||
| 
						 | 
					fe94ae0243 | ||
| 
						 | 
					8a1a22d4bd | ||
| 
						 | 
					153a578986 | ||
| 
						 | 
					04bb10d0a2 | ||
| 
						 | 
					35e52de2c1 | ||
| 
						 | 
					b0862fddaa | ||
| 
						 | 
					77735f5310 | ||
| 
						 | 
					e388756533 | ||
| 
						 | 
					e9ca9bb781 | ||
| 
						 | 
					e48918442c | ||
| 
						 | 
					52f37f41f0 | ||
| 
						 | 
					4687006fec | ||
| 
						 | 
					aca4ca3066 | ||
| 
						 | 
					3a2c00622a | ||
| 
						 | 
					3e749ec085 | ||
| 
						 | 
					ee2ec00069 | ||
| 
						 | 
					0aa2941868 | ||
| 
						 | 
					46cd1d5156 | ||
| 
						 | 
					07a5c41fd4 | ||
| 
						 | 
					4ad3c553d5 | ||
| 
						 | 
					d40cc448a5 | ||
| 
						 | 
					e2f3f9d348 | ||
| 
						 | 
					98d44950f8 | ||
| 
						 | 
					8ae9edb1ef | ||
| 
						 | 
					84c4396c13 | ||
| 
						 | 
					2b937a30e3 | ||
| 
						 | 
					b7815bfd86 | ||
| 
						 | 
					d94fa03411 | ||
| 
						 | 
					0a7007ef9e | ||
| 
						 | 
					dd12136dee | ||
| 
						 | 
					6e2f89fe3d | ||
| 
						 | 
					092085b9af | ||
| 
						 | 
					1c06eb8661 | ||
| 
						 | 
					c7e87b06b5 | ||
| 
						 | 
					38c738c199 | ||
| 
						 | 
					e899587307 | ||
| 
						 | 
					c9feb0b75f | ||
| 
						 | 
					10718c35d1 | ||
| 
						 | 
					4dc6a37bad | ||
| 
						 | 
					ac49fc7aba | ||
| 
						 | 
					e4f008800b | ||
| 
						 | 
					0b0ffd7bab | ||
| 
						 | 
					dfa77526a2 | ||
| 
						 | 
					9a3bd6c613 | ||
| 
						 | 
					1161de5746 | ||
| 
						 | 
					9df8e20391 | ||
| 
						 | 
					11047a9c95 | ||
| 
						 | 
					18fa66f61c | ||
| 
						 | 
					758a048f34 | ||
| 
						 | 
					ee0fc360b0 | ||
| 
						 | 
					4012f95ec1 | ||
| 
						 | 
					0336ce4606 | ||
| 
						 | 
					9ba36ab7e2 | ||
| 
						 | 
					fe7a08a1b0 | ||
| 
						 | 
					87a8f9cedc | ||
| 
						 | 
					01df7e20ca | ||
| 
						 | 
					d181219522 | ||
| 
						 | 
					6ae24b8135 | ||
| 
						 | 
					8e009f24f9 | ||
| 
						 | 
					53031f44ac | ||
| 
						 | 
					af5a988457 | ||
| 
						 | 
					bab0391a19 | ||
| 
						 | 
					444123c47e | ||
| 
						 | 
					f123d34046 | ||
| 
						 | 
					1b40f99f68 | ||
| 
						 | 
					b314b3ed2b | ||
| 
						 | 
					59b8932969 | ||
| 
						 | 
					107af753ec | ||
| 
						 | 
					1f0acb3046 | ||
| 
						 | 
					431e533929 | ||
| 
						 | 
					02c845cbc6 | ||
| 
						 | 
					628111ed20 | ||
| 
						 | 
					e825a9c02f | ||
| 
						 | 
					7a35bddf36 | ||
| 
						 | 
					ad69270af8 | ||
| 
						 | 
					404edf9483 | ||
| 
						 | 
					a166b4e9b6 | ||
| 
						 | 
					7a285f11db | 
							
								
								
									
										4
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							@@ -89,7 +89,7 @@ jobs:
 | 
			
		||||
        env:
 | 
			
		||||
          IS_TEST: "true"
 | 
			
		||||
      - name: Upload bundle stats
 | 
			
		||||
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
 | 
			
		||||
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
 | 
			
		||||
        with:
 | 
			
		||||
          name: frontend-bundle-stats
 | 
			
		||||
          path: build/stats/*.json
 | 
			
		||||
@@ -113,7 +113,7 @@ jobs:
 | 
			
		||||
        env:
 | 
			
		||||
          IS_TEST: "true"
 | 
			
		||||
      - name: Upload bundle stats
 | 
			
		||||
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
 | 
			
		||||
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
 | 
			
		||||
        with:
 | 
			
		||||
          name: supervisor-bundle-stats
 | 
			
		||||
          path: build/stats/*.json
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							@@ -36,14 +36,14 @@ jobs:
 | 
			
		||||
 | 
			
		||||
      # Initializes the CodeQL tools for scanning.
 | 
			
		||||
      - name: Initialize CodeQL
 | 
			
		||||
        uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
 | 
			
		||||
        uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
 | 
			
		||||
        with:
 | 
			
		||||
          languages: ${{ matrix.language }}
 | 
			
		||||
 | 
			
		||||
      # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
 | 
			
		||||
      # If this step fails, then you should remove it and run the build manually (see below)
 | 
			
		||||
      - name: Autobuild
 | 
			
		||||
        uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
 | 
			
		||||
        uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
 | 
			
		||||
 | 
			
		||||
      # ℹ️ Command-line programs to run using the OS shell.
 | 
			
		||||
      # 📚 https://git.io/JvXDl
 | 
			
		||||
@@ -57,4 +57,4 @@ jobs:
 | 
			
		||||
      #   make release
 | 
			
		||||
 | 
			
		||||
      - name: Perform CodeQL Analysis
 | 
			
		||||
        uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
 | 
			
		||||
        uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/nightly.yaml
									
									
									
									
										vendored
									
									
								
							@@ -57,14 +57,14 @@ jobs:
 | 
			
		||||
        run: tar -czvf translations.tar.gz translations
 | 
			
		||||
 | 
			
		||||
      - name: Upload build artifacts
 | 
			
		||||
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
 | 
			
		||||
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
 | 
			
		||||
        with:
 | 
			
		||||
          name: wheels
 | 
			
		||||
          path: dist/home_assistant_frontend*.whl
 | 
			
		||||
          if-no-files-found: error
 | 
			
		||||
 | 
			
		||||
      - name: Upload translations
 | 
			
		||||
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
 | 
			
		||||
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
 | 
			
		||||
        with:
 | 
			
		||||
          name: translations
 | 
			
		||||
          path: translations.tar.gz
 | 
			
		||||
 
 | 
			
		||||
@@ -16,9 +16,9 @@ import {
 | 
			
		||||
} from "../../../../src/common/auth/token_storage";
 | 
			
		||||
import { atLeastVersion } from "../../../../src/common/config/version";
 | 
			
		||||
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
 | 
			
		||||
import "../../../../src/components/ha-button";
 | 
			
		||||
import "../../../../src/components/ha-icon";
 | 
			
		||||
import "../../../../src/components/ha-list";
 | 
			
		||||
import "../../../../src/components/ha-button";
 | 
			
		||||
import "../../../../src/components/ha-list-item";
 | 
			
		||||
import "../../../../src/components/ha-svg-icon";
 | 
			
		||||
import {
 | 
			
		||||
@@ -28,7 +28,6 @@ import {
 | 
			
		||||
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
 | 
			
		||||
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
 | 
			
		||||
import "../../../../src/layouts/hass-loading-screen";
 | 
			
		||||
import { generateDefaultViewConfig } from "../../../../src/panels/lovelace/common/generate-lovelace-config";
 | 
			
		||||
import "./hc-layout";
 | 
			
		||||
 | 
			
		||||
@customElement("hc-cast")
 | 
			
		||||
@@ -96,7 +95,9 @@ class HcCast extends LitElement {
 | 
			
		||||
                <ha-list @action=${this._handlePickView} activatable>
 | 
			
		||||
                  ${(
 | 
			
		||||
                    this.lovelaceViews ?? [
 | 
			
		||||
                      generateDefaultViewConfig({}, {}, {}, {}, () => ""),
 | 
			
		||||
                      {
 | 
			
		||||
                        title: "Home",
 | 
			
		||||
                      },
 | 
			
		||||
                    ]
 | 
			
		||||
                  ).map(
 | 
			
		||||
                    (view, idx) => html`
 | 
			
		||||
 
 | 
			
		||||
@@ -5,14 +5,14 @@ subtitle: Dialogs provide important prompts in a user flow.
 | 
			
		||||
 | 
			
		||||
# Material Design 3
 | 
			
		||||
 | 
			
		||||
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guideliness. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
 | 
			
		||||
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guidelines. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
 | 
			
		||||
 | 
			
		||||
# Guidelines
 | 
			
		||||
 | 
			
		||||
## Design
 | 
			
		||||
 | 
			
		||||
- Dialogs have a max width of 560px. Alert and confirmation dialogs got a fixed width of 320px. If you need more width, consider a dedicated page instead.
 | 
			
		||||
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guideliness.
 | 
			
		||||
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
 | 
			
		||||
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
 | 
			
		||||
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
 | 
			
		||||
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
 | 
			
		||||
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
 | 
			
		||||
@@ -26,7 +26,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
 | 
			
		||||
 | 
			
		||||
- A best practice is to always use a title, even if it is optional by Material guidelines.
 | 
			
		||||
- People mainly read the title and a button. Put the most important information in those two.
 | 
			
		||||
- Try to avoid user generated content in the title, this could make the title unreadable long.
 | 
			
		||||
- Try to avoid user generated content in the title, this could make the title unreadably long.
 | 
			
		||||
- If users become unsure, they read the description. Make sure this explains what will happen.
 | 
			
		||||
- Strive for minimalism.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -39,6 +39,7 @@ const SENSOR_DEVICE_CLASSES = [
 | 
			
		||||
  "pm1",
 | 
			
		||||
  "pm10",
 | 
			
		||||
  "pm25",
 | 
			
		||||
  "pm4",
 | 
			
		||||
  "power_factor",
 | 
			
		||||
  "power",
 | 
			
		||||
  "precipitation",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								package.json
									
									
									
									
									
								
							@@ -28,8 +28,8 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@babel/runtime": "7.28.4",
 | 
			
		||||
    "@braintree/sanitize-url": "7.1.1",
 | 
			
		||||
    "@codemirror/autocomplete": "6.19.0",
 | 
			
		||||
    "@codemirror/commands": "6.9.0",
 | 
			
		||||
    "@codemirror/autocomplete": "6.19.1",
 | 
			
		||||
    "@codemirror/commands": "6.10.0",
 | 
			
		||||
    "@codemirror/language": "6.11.3",
 | 
			
		||||
    "@codemirror/legacy-modes": "6.5.2",
 | 
			
		||||
    "@codemirror/search": "6.5.11",
 | 
			
		||||
@@ -52,8 +52,8 @@
 | 
			
		||||
    "@fullcalendar/list": "6.1.19",
 | 
			
		||||
    "@fullcalendar/luxon3": "6.1.19",
 | 
			
		||||
    "@fullcalendar/timegrid": "6.1.19",
 | 
			
		||||
    "@home-assistant/webawesome": "3.0.0-beta.6.ha.5",
 | 
			
		||||
    "@lezer/highlight": "1.2.2",
 | 
			
		||||
    "@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
 | 
			
		||||
    "@lezer/highlight": "1.2.3",
 | 
			
		||||
    "@lit-labs/motion": "1.0.9",
 | 
			
		||||
    "@lit-labs/observers": "2.0.6",
 | 
			
		||||
    "@lit-labs/virtualizer": "2.1.1",
 | 
			
		||||
@@ -81,7 +81,7 @@
 | 
			
		||||
    "@material/mwc-top-app-bar": "0.27.0",
 | 
			
		||||
    "@material/mwc-top-app-bar-fixed": "0.27.0",
 | 
			
		||||
    "@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
 | 
			
		||||
    "@material/web": "2.4.0",
 | 
			
		||||
    "@material/web": "2.4.1",
 | 
			
		||||
    "@mdi/js": "7.4.47",
 | 
			
		||||
    "@mdi/svg": "7.4.47",
 | 
			
		||||
    "@replit/codemirror-indentation-markers": "6.5.3",
 | 
			
		||||
@@ -89,8 +89,8 @@
 | 
			
		||||
    "@thomasloven/round-slider": "0.6.0",
 | 
			
		||||
    "@tsparticles/engine": "3.9.1",
 | 
			
		||||
    "@tsparticles/preset-links": "3.2.0",
 | 
			
		||||
    "@vaadin/combo-box": "24.9.2",
 | 
			
		||||
    "@vaadin/vaadin-themable-mixin": "24.9.2",
 | 
			
		||||
    "@vaadin/combo-box": "24.9.4",
 | 
			
		||||
    "@vaadin/vaadin-themable-mixin": "24.9.4",
 | 
			
		||||
    "@vibrant/color": "4.0.0",
 | 
			
		||||
    "@vue/web-component-wrapper": "1.3.0",
 | 
			
		||||
    "@webcomponents/scoped-custom-element-registry": "0.0.10",
 | 
			
		||||
@@ -148,17 +148,17 @@
 | 
			
		||||
    "xss": "1.0.15"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@babel/core": "7.28.4",
 | 
			
		||||
    "@babel/core": "7.28.5",
 | 
			
		||||
    "@babel/helper-define-polyfill-provider": "0.6.5",
 | 
			
		||||
    "@babel/plugin-transform-runtime": "7.28.3",
 | 
			
		||||
    "@babel/preset-env": "7.28.3",
 | 
			
		||||
    "@babel/plugin-transform-runtime": "7.28.5",
 | 
			
		||||
    "@babel/preset-env": "7.28.5",
 | 
			
		||||
    "@bundle-stats/plugin-webpack-filter": "4.21.5",
 | 
			
		||||
    "@lokalise/node-api": "15.3.1",
 | 
			
		||||
    "@octokit/auth-oauth-device": "8.0.2",
 | 
			
		||||
    "@octokit/plugin-retry": "8.0.2",
 | 
			
		||||
    "@octokit/auth-oauth-device": "8.0.3",
 | 
			
		||||
    "@octokit/plugin-retry": "8.0.3",
 | 
			
		||||
    "@octokit/rest": "22.0.0",
 | 
			
		||||
    "@rsdoctor/rspack-plugin": "1.3.3",
 | 
			
		||||
    "@rspack/core": "1.5.8",
 | 
			
		||||
    "@rsdoctor/rspack-plugin": "1.3.6",
 | 
			
		||||
    "@rspack/core": "1.6.0",
 | 
			
		||||
    "@rspack/dev-server": "1.1.4",
 | 
			
		||||
    "@types/babel__plugin-transform-runtime": "7.9.5",
 | 
			
		||||
    "@types/chromecast-caf-receiver": "6.0.22",
 | 
			
		||||
@@ -173,12 +173,12 @@
 | 
			
		||||
    "@types/lodash.merge": "4.6.9",
 | 
			
		||||
    "@types/luxon": "3.7.1",
 | 
			
		||||
    "@types/mocha": "10.0.10",
 | 
			
		||||
    "@types/qrcode": "1.5.5",
 | 
			
		||||
    "@types/sortablejs": "1.15.8",
 | 
			
		||||
    "@types/qrcode": "1.5.6",
 | 
			
		||||
    "@types/sortablejs": "1.15.9",
 | 
			
		||||
    "@types/tar": "6.1.13",
 | 
			
		||||
    "@types/ua-parser-js": "0.7.39",
 | 
			
		||||
    "@types/webspeechapi": "0.0.29",
 | 
			
		||||
    "@vitest/coverage-v8": "3.2.4",
 | 
			
		||||
    "@vitest/coverage-v8": "4.0.5",
 | 
			
		||||
    "babel-loader": "10.0.0",
 | 
			
		||||
    "babel-plugin-template-html-minifier": "4.1.0",
 | 
			
		||||
    "browserslist-useragent-regexp": "4.1.3",
 | 
			
		||||
@@ -201,9 +201,9 @@
 | 
			
		||||
    "gulp-rename": "2.1.0",
 | 
			
		||||
    "html-minifier-terser": "7.2.0",
 | 
			
		||||
    "husky": "9.1.7",
 | 
			
		||||
    "jsdom": "27.0.1",
 | 
			
		||||
    "jsdom": "27.1.0",
 | 
			
		||||
    "jszip": "3.10.1",
 | 
			
		||||
    "lint-staged": "16.2.4",
 | 
			
		||||
    "lint-staged": "16.2.6",
 | 
			
		||||
    "lit-analyzer": "2.0.3",
 | 
			
		||||
    "lodash.merge": "4.6.2",
 | 
			
		||||
    "lodash.template": "4.5.0",
 | 
			
		||||
@@ -213,13 +213,13 @@
 | 
			
		||||
    "rspack-manifest-plugin": "5.1.0",
 | 
			
		||||
    "serve": "14.2.5",
 | 
			
		||||
    "sinon": "21.0.0",
 | 
			
		||||
    "tar": "7.5.1",
 | 
			
		||||
    "tar": "7.5.2",
 | 
			
		||||
    "terser-webpack-plugin": "5.3.14",
 | 
			
		||||
    "ts-lit-plugin": "2.0.2",
 | 
			
		||||
    "typescript": "5.9.3",
 | 
			
		||||
    "typescript-eslint": "8.46.1",
 | 
			
		||||
    "typescript-eslint": "8.46.2",
 | 
			
		||||
    "vite-tsconfig-paths": "5.1.4",
 | 
			
		||||
    "vitest": "3.2.4",
 | 
			
		||||
    "vitest": "4.0.5",
 | 
			
		||||
    "webpack-stats-plugin": "1.1.3",
 | 
			
		||||
    "webpackbar": "7.0.0",
 | 
			
		||||
    "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
 | 
			
		||||
@@ -235,5 +235,8 @@
 | 
			
		||||
    "tslib": "2.8.1",
 | 
			
		||||
    "@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
 | 
			
		||||
  },
 | 
			
		||||
  "packageManager": "yarn@4.10.3"
 | 
			
		||||
  "packageManager": "yarn@4.10.3",
 | 
			
		||||
  "volta": {
 | 
			
		||||
    "node": "22.21.1"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 | 
			
		||||
 | 
			
		||||
[project]
 | 
			
		||||
name         = "home-assistant-frontend"
 | 
			
		||||
version      = "20250924.0"
 | 
			
		||||
version      = "20251029.0"
 | 
			
		||||
license      = "Apache-2.0"
 | 
			
		||||
license-files = ["LICENSE*"]
 | 
			
		||||
description  = "The Home Assistant frontend"
 | 
			
		||||
 
 | 
			
		||||
@@ -214,6 +214,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
 | 
			
		||||
      "pm1",
 | 
			
		||||
      "pm10",
 | 
			
		||||
      "pm25",
 | 
			
		||||
      "pm4",
 | 
			
		||||
      "power_factor",
 | 
			
		||||
      "power",
 | 
			
		||||
      "pressure",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										116
									
								
								src/common/util/swipe-gesture-recognizer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/common/util/swipe-gesture-recognizer.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,116 @@
 | 
			
		||||
export interface SwipeGestureResult {
 | 
			
		||||
  velocity: number;
 | 
			
		||||
  delta: number;
 | 
			
		||||
  isSwipe: boolean;
 | 
			
		||||
  isDownwardSwipe: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SwipeGestureConfig {
 | 
			
		||||
  velocitySwipeThreshold?: number;
 | 
			
		||||
  movementTimeThreshold?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const VELOCITY_SWIPE_THRESHOLD = 0.5; // px/ms
 | 
			
		||||
const MOVEMENT_TIME_THRESHOLD = 100; // ms
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Recognizes swipe gestures and calculates velocity for touch interactions.
 | 
			
		||||
 * Tracks touch movement and provides velocity-based and position-based gesture detection.
 | 
			
		||||
 */
 | 
			
		||||
export class SwipeGestureRecognizer {
 | 
			
		||||
  private _startY = 0;
 | 
			
		||||
 | 
			
		||||
  private _delta = 0;
 | 
			
		||||
 | 
			
		||||
  private _startTime = 0;
 | 
			
		||||
 | 
			
		||||
  private _lastY = 0;
 | 
			
		||||
 | 
			
		||||
  private _lastTime = 0;
 | 
			
		||||
 | 
			
		||||
  private _velocityThreshold: number;
 | 
			
		||||
 | 
			
		||||
  private _movementTimeThreshold: number;
 | 
			
		||||
 | 
			
		||||
  constructor(config: SwipeGestureConfig = {}) {
 | 
			
		||||
    this._velocityThreshold =
 | 
			
		||||
      config.velocitySwipeThreshold ?? VELOCITY_SWIPE_THRESHOLD; // px/ms
 | 
			
		||||
    this._movementTimeThreshold =
 | 
			
		||||
      config.movementTimeThreshold ?? MOVEMENT_TIME_THRESHOLD; // ms
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Initialize gesture tracking with starting touch position
 | 
			
		||||
   */
 | 
			
		||||
  public start(clientY: number): void {
 | 
			
		||||
    const now = Date.now();
 | 
			
		||||
    this._startY = clientY;
 | 
			
		||||
    this._startTime = now;
 | 
			
		||||
    this._lastY = clientY;
 | 
			
		||||
    this._lastTime = now;
 | 
			
		||||
    this._delta = 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Update gesture state during movement
 | 
			
		||||
   * Returns the current delta (negative when dragging down)
 | 
			
		||||
   */
 | 
			
		||||
  public move(clientY: number): number {
 | 
			
		||||
    const now = Date.now();
 | 
			
		||||
    this._delta = this._startY - clientY;
 | 
			
		||||
    this._lastY = clientY;
 | 
			
		||||
    this._lastTime = now;
 | 
			
		||||
    return this._delta;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Calculate final gesture result when touch ends
 | 
			
		||||
   */
 | 
			
		||||
  public end(): SwipeGestureResult {
 | 
			
		||||
    const velocity = this.getVelocity();
 | 
			
		||||
    const hasSignificantVelocity = Math.abs(velocity) > this._velocityThreshold;
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      velocity,
 | 
			
		||||
      delta: this._delta,
 | 
			
		||||
      isSwipe: hasSignificantVelocity,
 | 
			
		||||
      isDownwardSwipe: velocity > 0,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get current drag delta (negative when dragging down)
 | 
			
		||||
   */
 | 
			
		||||
  public getDelta(): number {
 | 
			
		||||
    return this._delta;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Calculate velocity based on recent movement
 | 
			
		||||
   * Returns 0 if no recent movement detected
 | 
			
		||||
   * Positive velocity means downward swipe
 | 
			
		||||
   */
 | 
			
		||||
  public getVelocity(): number {
 | 
			
		||||
    const now = Date.now();
 | 
			
		||||
    const timeSinceLastMove = now - this._lastTime;
 | 
			
		||||
 | 
			
		||||
    // Only consider velocity if the last movement was recent
 | 
			
		||||
    if (timeSinceLastMove >= this._movementTimeThreshold) {
 | 
			
		||||
      return 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const timeDelta = this._lastTime - this._startTime;
 | 
			
		||||
    return timeDelta > 0 ? (this._lastY - this._startY) / timeDelta : 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Reset all tracking state
 | 
			
		||||
   */
 | 
			
		||||
  public reset(): void {
 | 
			
		||||
    this._startY = 0;
 | 
			
		||||
    this._delta = 0;
 | 
			
		||||
    this._startTime = 0;
 | 
			
		||||
    this._lastY = 0;
 | 
			
		||||
    this._lastTime = 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -35,6 +35,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
 | 
			
		||||
const LEGEND_OVERFLOW_LIMIT = 10;
 | 
			
		||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
 | 
			
		||||
const DOUBLE_TAP_TIME = 300;
 | 
			
		||||
const RESIZE_ANIMATION_DURATION = 250;
 | 
			
		||||
 | 
			
		||||
export type CustomLegendOption = ECOption["legend"] & {
 | 
			
		||||
  type: "custom";
 | 
			
		||||
@@ -88,9 +89,19 @@ export class HaChartBase extends LitElement {
 | 
			
		||||
 | 
			
		||||
  private _lastTapTime?: number;
 | 
			
		||||
 | 
			
		||||
  private _shouldResizeChart = false;
 | 
			
		||||
 | 
			
		||||
  // @ts-ignore
 | 
			
		||||
  private _resizeController = new ResizeController(this, {
 | 
			
		||||
    callback: () => this.chart?.resize(),
 | 
			
		||||
    callback: () => {
 | 
			
		||||
      if (this.chart) {
 | 
			
		||||
        if (!this.chart.getZr().animation.isFinished()) {
 | 
			
		||||
          this._shouldResizeChart = true;
 | 
			
		||||
        } else {
 | 
			
		||||
          this.chart.resize();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  private _loading = false;
 | 
			
		||||
@@ -195,6 +206,15 @@ export class HaChartBase extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
    if (changedProps.has("options")) {
 | 
			
		||||
      chartOptions = { ...chartOptions, ...this._createOptions() };
 | 
			
		||||
      if (
 | 
			
		||||
        this._compareCustomLegendOptions(
 | 
			
		||||
          changedProps.get("options"),
 | 
			
		||||
          this.options
 | 
			
		||||
        )
 | 
			
		||||
      ) {
 | 
			
		||||
        // custom legend changes may require a resize to layout properly
 | 
			
		||||
        this._shouldResizeChart = true;
 | 
			
		||||
      }
 | 
			
		||||
    } else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
 | 
			
		||||
      chartOptions.dataZoom = this._getDataZoomConfig();
 | 
			
		||||
    }
 | 
			
		||||
@@ -286,7 +306,7 @@ export class HaChartBase extends LitElement {
 | 
			
		||||
          itemStyle = {
 | 
			
		||||
            color: dataset?.color as string,
 | 
			
		||||
            ...(dataset?.itemStyle as { borderColor?: string }),
 | 
			
		||||
            itemStyle,
 | 
			
		||||
            ...itemStyle,
 | 
			
		||||
          };
 | 
			
		||||
          const color = itemStyle?.color as string;
 | 
			
		||||
          const borderColor = itemStyle?.borderColor as string;
 | 
			
		||||
@@ -366,6 +386,7 @@ export class HaChartBase extends LitElement {
 | 
			
		||||
      if (!this.options?.dataZoom) {
 | 
			
		||||
        this.chart.getZr().on("dblclick", this._handleClickZoom);
 | 
			
		||||
      }
 | 
			
		||||
      this.chart.on("finished", this._handleChartRenderFinished);
 | 
			
		||||
      if (this._isTouchDevice) {
 | 
			
		||||
        this.chart.getZr().on("click", (e: ECElementEvent) => {
 | 
			
		||||
          if (!e.zrByTouch) {
 | 
			
		||||
@@ -497,6 +518,7 @@ export class HaChartBase extends LitElement {
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    this.requestUpdate("_hiddenDatasets");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _getDataZoomConfig(): DataZoomComponentOption | undefined {
 | 
			
		||||
@@ -945,6 +967,33 @@ export class HaChartBase extends LitElement {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _handleChartRenderFinished = () => {
 | 
			
		||||
    if (this._shouldResizeChart) {
 | 
			
		||||
      this.chart?.resize({
 | 
			
		||||
        animation: this._reducedMotion
 | 
			
		||||
          ? undefined
 | 
			
		||||
          : { duration: RESIZE_ANIMATION_DURATION },
 | 
			
		||||
      });
 | 
			
		||||
      this._shouldResizeChart = false;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _compareCustomLegendOptions(
 | 
			
		||||
    oldOptions: ECOption | undefined,
 | 
			
		||||
    newOptions: ECOption | undefined
 | 
			
		||||
  ): boolean {
 | 
			
		||||
    const oldLegends = ensureArray(
 | 
			
		||||
      oldOptions?.legend || []
 | 
			
		||||
    ) as LegendComponentOption[];
 | 
			
		||||
    const newLegends = ensureArray(
 | 
			
		||||
      newOptions?.legend || []
 | 
			
		||||
    ) as LegendComponentOption[];
 | 
			
		||||
    return (
 | 
			
		||||
      oldLegends.some((l) => l.show && l.type === "custom") !==
 | 
			
		||||
      newLegends.some((l) => l.show && l.type === "custom")
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    :host {
 | 
			
		||||
      display: block;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								src/components/entity/const.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/entity/const.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";
 | 
			
		||||
@@ -147,6 +147,7 @@ class HaEntitiesPicker extends LitElement {
 | 
			
		||||
          .createDomains=${this.createDomains}
 | 
			
		||||
          .required=${this.required && !currentEntities.length}
 | 
			
		||||
          @value-changed=${this._addEntity}
 | 
			
		||||
          .addButton=${currentEntities.length > 0}
 | 
			
		||||
        ></ha-entity-picker>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
 
 | 
			
		||||
@@ -312,7 +312,7 @@ export class HaEntityNamePicker extends LitElement {
 | 
			
		||||
  private _toValue = memoizeOne(
 | 
			
		||||
    (items: EntityNameItem[]): typeof this.value => {
 | 
			
		||||
      if (items.length === 0) {
 | 
			
		||||
        return "";
 | 
			
		||||
        return undefined;
 | 
			
		||||
      }
 | 
			
		||||
      if (items.length === 1) {
 | 
			
		||||
        const item = items[0];
 | 
			
		||||
 
 | 
			
		||||
@@ -113,6 +113,9 @@ export class HaEntityPicker extends LitElement {
 | 
			
		||||
  @property({ attribute: "hide-clear-icon", type: Boolean })
 | 
			
		||||
  public hideClearIcon = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "add-button", type: Boolean })
 | 
			
		||||
  public addButton = false;
 | 
			
		||||
 | 
			
		||||
  @query("ha-generic-picker") private _picker?: HaGenericPicker;
 | 
			
		||||
 | 
			
		||||
  protected firstUpdated(changedProperties: PropertyValues): void {
 | 
			
		||||
@@ -281,7 +284,7 @@ export class HaEntityPicker extends LitElement {
 | 
			
		||||
        .searchLabel=${this.searchLabel}
 | 
			
		||||
        .notFoundLabel=${notFoundLabel}
 | 
			
		||||
        .placeholder=${placeholder}
 | 
			
		||||
        .value=${this.value}
 | 
			
		||||
        .value=${this.addButton ? undefined : this.value}
 | 
			
		||||
        .rowRenderer=${this._rowRenderer}
 | 
			
		||||
        .getItems=${this._getItems}
 | 
			
		||||
        .getAdditionalItems=${this._getAdditionalItems}
 | 
			
		||||
@@ -289,6 +292,9 @@ export class HaEntityPicker extends LitElement {
 | 
			
		||||
        .searchFn=${this._searchFn}
 | 
			
		||||
        .valueRenderer=${this._valueRenderer}
 | 
			
		||||
        @value-changed=${this._valueChanged}
 | 
			
		||||
        .addButtonLabel=${this.addButton
 | 
			
		||||
          ? this.hass.localize("ui.components.entity.entity-picker.add")
 | 
			
		||||
          : undefined}
 | 
			
		||||
      >
 | 
			
		||||
      </ha-generic-picker>
 | 
			
		||||
    `;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,39 @@
 | 
			
		||||
import { mdiDragHorizontalVariant } from "@mdi/js";
 | 
			
		||||
import "@material/mwc-menu/mwc-menu-surface";
 | 
			
		||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
 | 
			
		||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
 | 
			
		||||
import type { IFuseOptions } from "fuse.js";
 | 
			
		||||
import Fuse from "fuse.js";
 | 
			
		||||
import type { HassEntity } from "home-assistant-js-websocket";
 | 
			
		||||
import type { PropertyValues } from "lit";
 | 
			
		||||
import { LitElement, css, html, nothing } from "lit";
 | 
			
		||||
import { css, html, LitElement, nothing } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import { repeat } from "lit/directives/repeat";
 | 
			
		||||
import memoizeOne from "memoize-one";
 | 
			
		||||
import { ensureArray } from "../../common/array/ensure-array";
 | 
			
		||||
import { fireEvent } from "../../common/dom/fire_event";
 | 
			
		||||
import { stopPropagation } from "../../common/dom/stop_propagation";
 | 
			
		||||
import { computeDomain } from "../../common/entity/compute_domain";
 | 
			
		||||
import {
 | 
			
		||||
  STATE_DISPLAY_SPECIAL_CONTENT,
 | 
			
		||||
  STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
 | 
			
		||||
} from "../../state-display/state-display";
 | 
			
		||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
 | 
			
		||||
import "../ha-combo-box";
 | 
			
		||||
import "../ha-sortable";
 | 
			
		||||
import "../chips/ha-input-chip";
 | 
			
		||||
import "../chips/ha-assist-chip";
 | 
			
		||||
import "../chips/ha-chip-set";
 | 
			
		||||
import "../chips/ha-input-chip";
 | 
			
		||||
import "../ha-combo-box";
 | 
			
		||||
import type { HaComboBox } from "../ha-combo-box";
 | 
			
		||||
import "../ha-sortable";
 | 
			
		||||
 | 
			
		||||
interface StateContentOption {
 | 
			
		||||
  primary: string;
 | 
			
		||||
  value: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const rowRenderer: ComboBoxLitRenderer<StateContentOption> = (item) => html`
 | 
			
		||||
  <ha-combo-box-item type="button">
 | 
			
		||||
    <span slot="headline">${item.primary}</span>
 | 
			
		||||
  </ha-combo-box-item>
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
const HIDDEN_ATTRIBUTES = [
 | 
			
		||||
  "access_token",
 | 
			
		||||
@@ -74,7 +90,7 @@ const HIDDEN_ATTRIBUTES = [
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
@customElement("ha-entity-state-content-picker")
 | 
			
		||||
class HaEntityStatePicker extends LitElement {
 | 
			
		||||
export class HaStateContentPicker extends LitElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false }) public entityId?: string;
 | 
			
		||||
@@ -95,26 +111,28 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property() public helper?: string;
 | 
			
		||||
 | 
			
		||||
  @state() private _opened = false;
 | 
			
		||||
  @query(".container", true) private _container?: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
  @query("ha-combo-box", true) private _comboBox!: HaComboBox;
 | 
			
		||||
 | 
			
		||||
  protected shouldUpdate(changedProps: PropertyValues) {
 | 
			
		||||
    return !(!changedProps.has("_opened") && this._opened);
 | 
			
		||||
  }
 | 
			
		||||
  @state() private _opened = false;
 | 
			
		||||
 | 
			
		||||
  private options = memoizeOne(
 | 
			
		||||
  private _editIndex?: number;
 | 
			
		||||
 | 
			
		||||
  private _options = memoizeOne(
 | 
			
		||||
    (entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
 | 
			
		||||
      const domain = entityId ? computeDomain(entityId) : undefined;
 | 
			
		||||
      return [
 | 
			
		||||
        {
 | 
			
		||||
          label: this.hass.localize("ui.components.state-content-picker.state"),
 | 
			
		||||
          primary: this.hass.localize(
 | 
			
		||||
            "ui.components.state-content-picker.state"
 | 
			
		||||
          ),
 | 
			
		||||
          value: "state",
 | 
			
		||||
        },
 | 
			
		||||
        ...(allowName
 | 
			
		||||
          ? [
 | 
			
		||||
              {
 | 
			
		||||
                label: this.hass.localize(
 | 
			
		||||
                primary: this.hass.localize(
 | 
			
		||||
                  "ui.components.state-content-picker.name"
 | 
			
		||||
                ),
 | 
			
		||||
                value: "name",
 | 
			
		||||
@@ -122,13 +140,13 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
            ]
 | 
			
		||||
          : []),
 | 
			
		||||
        {
 | 
			
		||||
          label: this.hass.localize(
 | 
			
		||||
          primary: this.hass.localize(
 | 
			
		||||
            "ui.components.state-content-picker.last_changed"
 | 
			
		||||
          ),
 | 
			
		||||
          value: "last_changed",
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          label: this.hass.localize(
 | 
			
		||||
          primary: this.hass.localize(
 | 
			
		||||
            "ui.components.state-content-picker.last_updated"
 | 
			
		||||
          ),
 | 
			
		||||
          value: "last_updated",
 | 
			
		||||
@@ -137,7 +155,7 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
          ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
 | 
			
		||||
              STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
 | 
			
		||||
            ).map((content) => ({
 | 
			
		||||
              label: this.hass.localize(
 | 
			
		||||
              primary: this.hass.localize(
 | 
			
		||||
                `ui.components.state-content-picker.${content}`
 | 
			
		||||
              ),
 | 
			
		||||
              value: content,
 | 
			
		||||
@@ -146,108 +164,201 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
        ...Object.keys(stateObj?.attributes ?? {})
 | 
			
		||||
          .filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
 | 
			
		||||
          .map((attribute) => ({
 | 
			
		||||
            primary: this.hass.formatEntityAttributeName(stateObj!, attribute),
 | 
			
		||||
            value: attribute,
 | 
			
		||||
            label: this.hass.formatEntityAttributeName(stateObj!, attribute),
 | 
			
		||||
          })),
 | 
			
		||||
      ];
 | 
			
		||||
      ] satisfies StateContentOption[];
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  private _filter = "";
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    if (!this.hass) {
 | 
			
		||||
      return nothing;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const value = this._value;
 | 
			
		||||
 | 
			
		||||
    const stateObj = this.entityId
 | 
			
		||||
      ? this.hass.states[this.entityId]
 | 
			
		||||
      : undefined;
 | 
			
		||||
 | 
			
		||||
    const options = this.options(this.entityId, stateObj, this.allowName);
 | 
			
		||||
    const optionItems = options.filter(
 | 
			
		||||
      (option) => !this._value.includes(option.value)
 | 
			
		||||
    );
 | 
			
		||||
    const options = this._options(this.entityId, stateObj, this.allowName);
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      ${value?.length
 | 
			
		||||
        ? html`
 | 
			
		||||
            <ha-sortable
 | 
			
		||||
              no-style
 | 
			
		||||
              @item-moved=${this._moveItem}
 | 
			
		||||
              .disabled=${this.disabled}
 | 
			
		||||
              handle-selector="button.primary.action"
 | 
			
		||||
            >
 | 
			
		||||
              <ha-chip-set>
 | 
			
		||||
                ${repeat(
 | 
			
		||||
                  this._value,
 | 
			
		||||
                  (item) => item,
 | 
			
		||||
                  (item, idx) => {
 | 
			
		||||
                    const label =
 | 
			
		||||
                      options.find((option) => option.value === item)?.label ||
 | 
			
		||||
                      item;
 | 
			
		||||
                    return html`
 | 
			
		||||
                      <ha-input-chip
 | 
			
		||||
                        .idx=${idx}
 | 
			
		||||
                        @remove=${this._removeItem}
 | 
			
		||||
                        .label=${label}
 | 
			
		||||
                        selected
 | 
			
		||||
                      >
 | 
			
		||||
                        <ha-svg-icon
 | 
			
		||||
                          slot="icon"
 | 
			
		||||
                          .path=${mdiDragHorizontalVariant}
 | 
			
		||||
                        ></ha-svg-icon>
 | 
			
		||||
                        ${label}
 | 
			
		||||
                      </ha-input-chip>
 | 
			
		||||
                    `;
 | 
			
		||||
                  }
 | 
			
		||||
                )}
 | 
			
		||||
              </ha-chip-set>
 | 
			
		||||
            </ha-sortable>
 | 
			
		||||
          `
 | 
			
		||||
        : nothing}
 | 
			
		||||
      ${this.label ? html`<label>${this.label}</label>` : nothing}
 | 
			
		||||
      <div class="container ${this.disabled ? "disabled" : ""}">
 | 
			
		||||
        <ha-sortable
 | 
			
		||||
          no-style
 | 
			
		||||
          @item-moved=${this._moveItem}
 | 
			
		||||
          .disabled=${this.disabled}
 | 
			
		||||
          handle-selector="button.primary.action"
 | 
			
		||||
          filter=".add"
 | 
			
		||||
        >
 | 
			
		||||
          <ha-chip-set>
 | 
			
		||||
            ${repeat(
 | 
			
		||||
              this._value,
 | 
			
		||||
              (item) => item,
 | 
			
		||||
              (item: string, idx) => {
 | 
			
		||||
                const label = options.find((o) => o.value === item)?.primary;
 | 
			
		||||
                const isValid = !!label;
 | 
			
		||||
                return html`
 | 
			
		||||
                  <ha-input-chip
 | 
			
		||||
                    data-idx=${idx}
 | 
			
		||||
                    @remove=${this._removeItem}
 | 
			
		||||
                    @click=${this._editItem}
 | 
			
		||||
                    .label=${label || item}
 | 
			
		||||
                    .selected=${!this.disabled}
 | 
			
		||||
                    .disabled=${this.disabled}
 | 
			
		||||
                    class=${!isValid ? "invalid" : ""}
 | 
			
		||||
                  >
 | 
			
		||||
                    <ha-svg-icon
 | 
			
		||||
                      slot="icon"
 | 
			
		||||
                      .path=${mdiDragHorizontalVariant}
 | 
			
		||||
                    ></ha-svg-icon>
 | 
			
		||||
                  </ha-input-chip>
 | 
			
		||||
                `;
 | 
			
		||||
              }
 | 
			
		||||
            )}
 | 
			
		||||
            ${this.disabled
 | 
			
		||||
              ? nothing
 | 
			
		||||
              : html`
 | 
			
		||||
                  <ha-assist-chip
 | 
			
		||||
                    @click=${this._addItem}
 | 
			
		||||
                    .disabled=${this.disabled}
 | 
			
		||||
                    label=${this.hass.localize(
 | 
			
		||||
                      "ui.components.entity.entity-state-content-picker.add"
 | 
			
		||||
                    )}
 | 
			
		||||
                    class="add"
 | 
			
		||||
                  >
 | 
			
		||||
                    <ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
 | 
			
		||||
                  </ha-assist-chip>
 | 
			
		||||
                `}
 | 
			
		||||
          </ha-chip-set>
 | 
			
		||||
        </ha-sortable>
 | 
			
		||||
 | 
			
		||||
      <ha-combo-box
 | 
			
		||||
        item-value-path="value"
 | 
			
		||||
        item-label-path="label"
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .label=${this.label}
 | 
			
		||||
        .helper=${this.helper}
 | 
			
		||||
        .disabled=${this.disabled}
 | 
			
		||||
        .required=${this.required && !value.length}
 | 
			
		||||
        .value=${""}
 | 
			
		||||
        .items=${optionItems}
 | 
			
		||||
        allow-custom-value
 | 
			
		||||
        @filter-changed=${this._filterChanged}
 | 
			
		||||
        @value-changed=${this._comboBoxValueChanged}
 | 
			
		||||
        @opened-changed=${this._openedChanged}
 | 
			
		||||
      ></ha-combo-box>
 | 
			
		||||
        <mwc-menu-surface
 | 
			
		||||
          .open=${this._opened}
 | 
			
		||||
          @closed=${this._onClosed}
 | 
			
		||||
          @opened=${this._onOpened}
 | 
			
		||||
          @input=${stopPropagation}
 | 
			
		||||
          .anchor=${this._container}
 | 
			
		||||
        >
 | 
			
		||||
          <ha-combo-box
 | 
			
		||||
            .hass=${this.hass}
 | 
			
		||||
            .value=${""}
 | 
			
		||||
            .autofocus=${this.autofocus}
 | 
			
		||||
            .disabled=${this.disabled || !this.entityId}
 | 
			
		||||
            .required=${this.required && !value.length}
 | 
			
		||||
            .helper=${this.helper}
 | 
			
		||||
            .items=${options}
 | 
			
		||||
            allow-custom-value
 | 
			
		||||
            item-id-path="value"
 | 
			
		||||
            item-value-path="value"
 | 
			
		||||
            item-label-path="primary"
 | 
			
		||||
            .renderer=${rowRenderer}
 | 
			
		||||
            @opened-changed=${this._openedChanged}
 | 
			
		||||
            @value-changed=${this._comboBoxValueChanged}
 | 
			
		||||
            @filter-changed=${this._filterChanged}
 | 
			
		||||
          >
 | 
			
		||||
          </ha-combo-box>
 | 
			
		||||
        </mwc-menu-surface>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _onClosed(ev) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    this._opened = false;
 | 
			
		||||
    this._editIndex = undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _onOpened(ev) {
 | 
			
		||||
    if (!this._opened) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    this._opened = true;
 | 
			
		||||
    await this._comboBox?.focus();
 | 
			
		||||
    await this._comboBox?.open();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _addItem(ev) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    this._opened = true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _editItem(ev) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    const idx = parseInt(ev.currentTarget.dataset.idx, 10);
 | 
			
		||||
    this._editIndex = idx;
 | 
			
		||||
    this._opened = true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private get _value() {
 | 
			
		||||
    return !this.value ? [] : ensureArray(this.value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _toValue = memoizeOne((value: string[]): typeof this.value => {
 | 
			
		||||
    if (value.length === 0) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
    if (value.length === 1) {
 | 
			
		||||
      return value[0];
 | 
			
		||||
    }
 | 
			
		||||
    return value;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  private _openedChanged(ev: ValueChangedEvent<boolean>) {
 | 
			
		||||
    this._opened = ev.detail.value;
 | 
			
		||||
    this._comboBox.filteredItems = this._comboBox.items;
 | 
			
		||||
    const open = ev.detail.value;
 | 
			
		||||
    if (open) {
 | 
			
		||||
      const options = this._comboBox.items || [];
 | 
			
		||||
 | 
			
		||||
      const initialValue =
 | 
			
		||||
        this._editIndex != null ? this._value[this._editIndex] : "";
 | 
			
		||||
      const filteredItems = this._filterSelectedOptions(options, initialValue);
 | 
			
		||||
 | 
			
		||||
      this._comboBox.filteredItems = filteredItems;
 | 
			
		||||
      this._comboBox.setInputValue(initialValue);
 | 
			
		||||
    } else {
 | 
			
		||||
      this._opened = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _filterChanged(ev?: CustomEvent): void {
 | 
			
		||||
    this._filter = ev?.detail.value || "";
 | 
			
		||||
  private _filterSelectedOptions = (
 | 
			
		||||
    options: StateContentOption[],
 | 
			
		||||
    current?: string
 | 
			
		||||
  ) => {
 | 
			
		||||
    const value = this._value;
 | 
			
		||||
 | 
			
		||||
    const filteredItems = this._comboBox.items?.filter((item) => {
 | 
			
		||||
      const label = item.label || item.value;
 | 
			
		||||
      return label.toLowerCase().includes(this._filter?.toLowerCase());
 | 
			
		||||
    });
 | 
			
		||||
    return options.filter(
 | 
			
		||||
      (option) => !value.includes(option.value) || option.value === current
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
    if (this._filter) {
 | 
			
		||||
      filteredItems?.unshift({ label: this._filter, value: this._filter });
 | 
			
		||||
  private _filterChanged(ev: ValueChangedEvent<string>) {
 | 
			
		||||
    const input = ev.detail.value;
 | 
			
		||||
    const filter = input?.toLowerCase() || "";
 | 
			
		||||
    const options = this._comboBox.items || [];
 | 
			
		||||
 | 
			
		||||
    const currentValue =
 | 
			
		||||
      this._editIndex != null ? this._value[this._editIndex] : "";
 | 
			
		||||
 | 
			
		||||
    this._comboBox.filteredItems = this._filterSelectedOptions(
 | 
			
		||||
      options,
 | 
			
		||||
      currentValue
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!filter) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const fuseOptions: IFuseOptions<StateContentOption> = {
 | 
			
		||||
      keys: ["primary", "secondary", "value"],
 | 
			
		||||
      isCaseSensitive: false,
 | 
			
		||||
      minMatchCharLength: Math.min(filter.length, 2),
 | 
			
		||||
      threshold: 0.2,
 | 
			
		||||
      ignoreDiacritics: true,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
 | 
			
		||||
    const filteredItems = fuse.search(filter).map((result) => result.item);
 | 
			
		||||
 | 
			
		||||
    this._comboBox.filteredItems = filteredItems;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -260,43 +371,40 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
    newValue.splice(newIndex, 0, element);
 | 
			
		||||
    this._setValue(newValue);
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    this._filterChanged();
 | 
			
		||||
    this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _removeItem(ev) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    const value: string[] = [...this._value];
 | 
			
		||||
    value.splice(ev.target.idx, 1);
 | 
			
		||||
    const value = [...this._value];
 | 
			
		||||
    const idx = parseInt(ev.target.dataset.idx, 10);
 | 
			
		||||
    value.splice(idx, 1);
 | 
			
		||||
    this._setValue(value);
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    this._filterChanged();
 | 
			
		||||
    this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _comboBoxValueChanged(ev: CustomEvent): void {
 | 
			
		||||
  private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    const newValue = ev.detail.value;
 | 
			
		||||
    const value = ev.detail.value;
 | 
			
		||||
 | 
			
		||||
    if (this.disabled || newValue === "") {
 | 
			
		||||
    if (this.disabled || value === "") {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const currentValue = this._value;
 | 
			
		||||
    const newValue = [...this._value];
 | 
			
		||||
 | 
			
		||||
    if (currentValue.includes(newValue)) {
 | 
			
		||||
      return;
 | 
			
		||||
    if (this._editIndex != null) {
 | 
			
		||||
      newValue[this._editIndex] = value;
 | 
			
		||||
    } else {
 | 
			
		||||
      newValue.push(value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this._filterChanged();
 | 
			
		||||
      this._comboBox.setInputValue("");
 | 
			
		||||
    }, 0);
 | 
			
		||||
 | 
			
		||||
    this._setValue([...currentValue, newValue]);
 | 
			
		||||
    this._setValue(newValue);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _setValue(value: string[]) {
 | 
			
		||||
    const newValue =
 | 
			
		||||
      value.length === 0 ? undefined : value.length === 1 ? value[0] : value;
 | 
			
		||||
    const newValue = this._toValue(value);
 | 
			
		||||
    this.value = newValue;
 | 
			
		||||
    fireEvent(this, "value-changed", {
 | 
			
		||||
      value: newValue,
 | 
			
		||||
@@ -306,10 +414,64 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    :host {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .container {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      background-color: var(--mdc-text-field-fill-color, whitesmoke);
 | 
			
		||||
      border-radius: var(--ha-border-radius-sm);
 | 
			
		||||
      border-end-end-radius: var(--ha-border-radius-square);
 | 
			
		||||
      border-end-start-radius: var(--ha-border-radius-square);
 | 
			
		||||
    }
 | 
			
		||||
    .container:after {
 | 
			
		||||
      display: block;
 | 
			
		||||
      content: "";
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      pointer-events: none;
 | 
			
		||||
      bottom: 0;
 | 
			
		||||
      left: 0;
 | 
			
		||||
      right: 0;
 | 
			
		||||
      height: 1px;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      background-color: var(
 | 
			
		||||
        --mdc-text-field-idle-line-color,
 | 
			
		||||
        rgba(0, 0, 0, 0.42)
 | 
			
		||||
      );
 | 
			
		||||
      transform:
 | 
			
		||||
        height 180ms ease-in-out,
 | 
			
		||||
        background-color 180ms ease-in-out;
 | 
			
		||||
    }
 | 
			
		||||
    .container.disabled:after {
 | 
			
		||||
      background-color: var(
 | 
			
		||||
        --mdc-text-field-disabled-line-color,
 | 
			
		||||
        rgba(0, 0, 0, 0.42)
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    .container:focus-within:after {
 | 
			
		||||
      height: 2px;
 | 
			
		||||
      background-color: var(--mdc-theme-primary);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    label {
 | 
			
		||||
      display: block;
 | 
			
		||||
      margin: 0 0 var(--ha-space-2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .add {
 | 
			
		||||
      order: 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    mwc-menu-surface {
 | 
			
		||||
      --mdc-menu-min-width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ha-chip-set {
 | 
			
		||||
      padding: 8px 0;
 | 
			
		||||
      padding: var(--ha-space-2) var(--ha-space-2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .invalid {
 | 
			
		||||
      text-decoration: line-through;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .sortable-fallback {
 | 
			
		||||
@@ -329,6 +491,6 @@ class HaEntityStatePicker extends LitElement {
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  interface HTMLElementTagNameMap {
 | 
			
		||||
    "ha-entity-state-content-picker": HaEntityStatePicker;
 | 
			
		||||
    "ha-entity-state-content-picker": HaStateContentPicker;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import { customElement, property } from "lit/decorators";
 | 
			
		||||
import { keyed } from "lit/directives/keyed";
 | 
			
		||||
import { repeat } from "lit/directives/repeat";
 | 
			
		||||
import { fireEvent } from "../../common/dom/fire_event";
 | 
			
		||||
import { ANY_STATE_VALUE } from "./const";
 | 
			
		||||
import { ensureArray } from "../../common/array/ensure-array";
 | 
			
		||||
import type { HomeAssistant } from "../../types";
 | 
			
		||||
import "./ha-entity-state-picker";
 | 
			
		||||
@@ -57,6 +58,7 @@ export class HaEntityStatesPicker extends LitElement {
 | 
			
		||||
 | 
			
		||||
    const value = this.value || [];
 | 
			
		||||
    const hide = [...(this.hideStates || []), ...value];
 | 
			
		||||
    const hideValue = value.includes(ANY_STATE_VALUE);
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      ${repeat(
 | 
			
		||||
@@ -84,7 +86,7 @@ export class HaEntityStatesPicker extends LitElement {
 | 
			
		||||
        `
 | 
			
		||||
      )}
 | 
			
		||||
      <div>
 | 
			
		||||
        ${this.disabled && value.length
 | 
			
		||||
        ${(this.disabled && value.length) || hideValue
 | 
			
		||||
          ? nothing
 | 
			
		||||
          : keyed(
 | 
			
		||||
              value.length,
 | 
			
		||||
 
 | 
			
		||||
@@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement {
 | 
			
		||||
        </span>
 | 
			
		||||
        <ha-switch
 | 
			
		||||
          @change=${this._handleRowClick}
 | 
			
		||||
          .checked=${baseEnabled}
 | 
			
		||||
          .checked=${!!baseEnabled}
 | 
			
		||||
          .preference=${"base"}
 | 
			
		||||
          .disabled=${loading}
 | 
			
		||||
          name="base"
 | 
			
		||||
@@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement {
 | 
			
		||||
              <ha-switch
 | 
			
		||||
                .id="switch-${preference}"
 | 
			
		||||
                @change=${this._handleRowClick}
 | 
			
		||||
                .checked=${this.analytics?.preferences[preference]}
 | 
			
		||||
                .checked=${!!this.analytics?.preferences[preference]}
 | 
			
		||||
                .preference=${preference}
 | 
			
		||||
                name=${preference}
 | 
			
		||||
              >
 | 
			
		||||
@@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement {
 | 
			
		||||
        </span>
 | 
			
		||||
        <ha-switch
 | 
			
		||||
          @change=${this._handleRowClick}
 | 
			
		||||
          .checked=${this.analytics?.preferences.diagnostics}
 | 
			
		||||
          .checked=${!!this.analytics?.preferences.diagnostics}
 | 
			
		||||
          .preference=${"diagnostics"}
 | 
			
		||||
          .disabled=${loading}
 | 
			
		||||
          name="diagnostics"
 | 
			
		||||
 
 | 
			
		||||
@@ -118,7 +118,7 @@ export class HaAutomationRow extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
    .row {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      padding: 0 8px;
 | 
			
		||||
      padding: var(--ha-space-0) var(--ha-space-2);
 | 
			
		||||
      min-height: 48px;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
@@ -134,12 +134,12 @@ export class HaAutomationRow extends LitElement {
 | 
			
		||||
    .expand-button {
 | 
			
		||||
      transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
 | 
			
		||||
      color: var(--ha-color-on-neutral-quiet);
 | 
			
		||||
      margin-left: -8px;
 | 
			
		||||
      margin-left: calc(var(--ha-space-2) * -1);
 | 
			
		||||
    }
 | 
			
		||||
    :host([building-block]) .leading-icon-wrapper {
 | 
			
		||||
      background-color: var(--ha-color-fill-neutral-loud-resting);
 | 
			
		||||
      border-radius: var(--ha-border-radius-md);
 | 
			
		||||
      padding: 4px;
 | 
			
		||||
      padding: var(--ha-space-1);
 | 
			
		||||
      display: flex;
 | 
			
		||||
      justify-content: center;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
@@ -149,7 +149,7 @@ export class HaAutomationRow extends LitElement {
 | 
			
		||||
      color: var(--ha-color-on-neutral-quiet);
 | 
			
		||||
    }
 | 
			
		||||
    :host([building-block]) ::slotted([slot="leading-icon"]) {
 | 
			
		||||
      --mdc-icon-size: 20px;
 | 
			
		||||
      --mdc-icon-size: var(--ha-space-5);
 | 
			
		||||
      color: var(--white-color);
 | 
			
		||||
      transform: rotate(-45deg);
 | 
			
		||||
    }
 | 
			
		||||
@@ -170,7 +170,7 @@ export class HaAutomationRow extends LitElement {
 | 
			
		||||
    ::slotted([slot="header"]) {
 | 
			
		||||
      flex: 1;
 | 
			
		||||
      overflow-wrap: anywhere;
 | 
			
		||||
      margin: 0 12px;
 | 
			
		||||
      margin: var(--ha-space-0) var(--ha-space-3);
 | 
			
		||||
    }
 | 
			
		||||
    :host([sort-selected]) .row {
 | 
			
		||||
      outline: solid;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
import "@home-assistant/webawesome/dist/components/drawer/drawer";
 | 
			
		||||
import { css, html, LitElement, type PropertyValues } from "lit";
 | 
			
		||||
import { customElement, property, state } from "lit/decorators";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
 | 
			
		||||
import { haStyleScrollbar } from "../resources/styles";
 | 
			
		||||
 | 
			
		||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
 | 
			
		||||
 | 
			
		||||
@@ -13,6 +15,12 @@ export class HaBottomSheet extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @state() private _drawerOpen = false;
 | 
			
		||||
 | 
			
		||||
  @query("#drawer") private _drawer!: HTMLElement;
 | 
			
		||||
 | 
			
		||||
  private _gestureRecognizer = new SwipeGestureRecognizer();
 | 
			
		||||
 | 
			
		||||
  private _isDragging = false;
 | 
			
		||||
 | 
			
		||||
  private _handleAfterHide() {
 | 
			
		||||
    this.open = false;
 | 
			
		||||
    const ev = new Event("closed", {
 | 
			
		||||
@@ -32,54 +40,186 @@ export class HaBottomSheet extends LitElement {
 | 
			
		||||
  render() {
 | 
			
		||||
    return html`
 | 
			
		||||
      <wa-drawer
 | 
			
		||||
        id="drawer"
 | 
			
		||||
        placement="bottom"
 | 
			
		||||
        .open=${this._drawerOpen}
 | 
			
		||||
        @wa-after-hide=${this._handleAfterHide}
 | 
			
		||||
        without-header
 | 
			
		||||
        @touchstart=${this._handleTouchStart}
 | 
			
		||||
      >
 | 
			
		||||
        <slot></slot>
 | 
			
		||||
        <slot name="header"></slot>
 | 
			
		||||
        <div id="body" class="body ha-scrollbar">
 | 
			
		||||
          <slot></slot>
 | 
			
		||||
        </div>
 | 
			
		||||
      </wa-drawer>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    wa-drawer {
 | 
			
		||||
      --wa-color-surface-raised: transparent;
 | 
			
		||||
      --spacing: 0;
 | 
			
		||||
      --size: var(--ha-bottom-sheet-height, auto);
 | 
			
		||||
      --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
 | 
			
		||||
      --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
 | 
			
		||||
    }
 | 
			
		||||
    wa-drawer::part(dialog) {
 | 
			
		||||
      max-height: var(--ha-bottom-sheet-max-height, 90vh);
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
    wa-drawer::part(body) {
 | 
			
		||||
      max-width: var(--ha-bottom-sheet-max-width);
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      border-top-left-radius: var(
 | 
			
		||||
        --ha-bottom-sheet-border-radius,
 | 
			
		||||
        var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
 | 
			
		||||
      );
 | 
			
		||||
      border-top-right-radius: var(
 | 
			
		||||
        --ha-bottom-sheet-border-radius,
 | 
			
		||||
        var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
 | 
			
		||||
      );
 | 
			
		||||
      background-color: var(
 | 
			
		||||
        --ha-bottom-sheet-surface-background,
 | 
			
		||||
        var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
 | 
			
		||||
      );
 | 
			
		||||
      padding: var(
 | 
			
		||||
        --ha-bottom-sheet-padding,
 | 
			
		||||
        0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
 | 
			
		||||
          var(--safe-area-inset-left)
 | 
			
		||||
      );
 | 
			
		||||
  private _handleTouchStart = (ev: TouchEvent) => {
 | 
			
		||||
    // Check if any element inside drawer in the composed path has scrollTop > 0
 | 
			
		||||
    for (const path of ev.composedPath()) {
 | 
			
		||||
      const el = path as HTMLElement;
 | 
			
		||||
      if (el === this._drawer) {
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      if (el.scrollTop > 0) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :host([flexcontent]) wa-drawer::part(body) {
 | 
			
		||||
      display: flex;
 | 
			
		||||
    this._startResizing(ev.touches[0].clientY);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _startResizing(clientY: number) {
 | 
			
		||||
    // register event listeners for drag handling
 | 
			
		||||
    document.addEventListener("touchmove", this._handleTouchMove, {
 | 
			
		||||
      passive: false,
 | 
			
		||||
    });
 | 
			
		||||
    document.addEventListener("touchend", this._handleTouchEnd);
 | 
			
		||||
    document.addEventListener("touchcancel", this._handleTouchEnd);
 | 
			
		||||
 | 
			
		||||
    this._gestureRecognizer.start(clientY);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _handleTouchMove = (ev: TouchEvent) => {
 | 
			
		||||
    const currentY = ev.touches[0].clientY;
 | 
			
		||||
    const delta = this._gestureRecognizer.move(currentY);
 | 
			
		||||
 | 
			
		||||
    if (delta < 0) {
 | 
			
		||||
      ev.preventDefault();
 | 
			
		||||
      this._isDragging = true;
 | 
			
		||||
      requestAnimationFrame(() => {
 | 
			
		||||
        if (this._isDragging) {
 | 
			
		||||
          this.style.setProperty(
 | 
			
		||||
            "--dialog-transform",
 | 
			
		||||
            `translateY(${delta * -1}px)`
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _animateSnapBack() {
 | 
			
		||||
    // Add transition for smooth animation
 | 
			
		||||
    this.style.setProperty(
 | 
			
		||||
      "--dialog-transition",
 | 
			
		||||
      `transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease-out`
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Reset transform to snap back
 | 
			
		||||
    this.style.removeProperty("--dialog-transform");
 | 
			
		||||
 | 
			
		||||
    // Remove transition after animation completes
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.style.removeProperty("--dialog-transition");
 | 
			
		||||
    }, BOTTOM_SHEET_ANIMATION_DURATION_MS);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _handleTouchEnd = () => {
 | 
			
		||||
    this._unregisterResizeHandlers();
 | 
			
		||||
 | 
			
		||||
    this._isDragging = false;
 | 
			
		||||
 | 
			
		||||
    const result = this._gestureRecognizer.end();
 | 
			
		||||
 | 
			
		||||
    // If velocity exceeds threshold, use velocity direction to determine action
 | 
			
		||||
    if (result.isSwipe) {
 | 
			
		||||
      if (result.isDownwardSwipe) {
 | 
			
		||||
        // Downward swipe - close the bottom sheet
 | 
			
		||||
        this._drawerOpen = false;
 | 
			
		||||
      } else {
 | 
			
		||||
        // Upward swipe - keep open and animate back
 | 
			
		||||
        this._animateSnapBack();
 | 
			
		||||
      }
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If velocity is below threshold, use position-based logic
 | 
			
		||||
    // Get the drawer height to calculate 50% threshold
 | 
			
		||||
    const drawerBody = this._drawer.shadowRoot?.querySelector(
 | 
			
		||||
      '[part="body"]'
 | 
			
		||||
    ) as HTMLElement;
 | 
			
		||||
    const drawerHeight = drawerBody?.offsetHeight || 0;
 | 
			
		||||
 | 
			
		||||
    // delta is negative when dragging down
 | 
			
		||||
    // Close if dragged down past 50% of the drawer height
 | 
			
		||||
    if (
 | 
			
		||||
      drawerHeight > 0 &&
 | 
			
		||||
      result.delta < 0 &&
 | 
			
		||||
      Math.abs(result.delta) > drawerHeight * 0.5
 | 
			
		||||
    ) {
 | 
			
		||||
      this._drawerOpen = false;
 | 
			
		||||
    } else {
 | 
			
		||||
      this._animateSnapBack();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _unregisterResizeHandlers = () => {
 | 
			
		||||
    document.removeEventListener("touchmove", this._handleTouchMove);
 | 
			
		||||
    document.removeEventListener("touchend", this._handleTouchEnd);
 | 
			
		||||
    document.removeEventListener("touchcancel", this._handleTouchEnd);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  disconnectedCallback() {
 | 
			
		||||
    super.disconnectedCallback();
 | 
			
		||||
    this._unregisterResizeHandlers();
 | 
			
		||||
    this._isDragging = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = [
 | 
			
		||||
    haStyleScrollbar,
 | 
			
		||||
    css`
 | 
			
		||||
      wa-drawer {
 | 
			
		||||
        --wa-color-surface-raised: transparent;
 | 
			
		||||
        --spacing: 0;
 | 
			
		||||
        --size: var(--ha-bottom-sheet-height, auto);
 | 
			
		||||
        --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
 | 
			
		||||
        --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
 | 
			
		||||
      }
 | 
			
		||||
      wa-drawer::part(dialog) {
 | 
			
		||||
        max-height: var(--ha-bottom-sheet-max-height, 90vh);
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        transform: var(--dialog-transform);
 | 
			
		||||
        transition: var(--dialog-transition);
 | 
			
		||||
      }
 | 
			
		||||
      wa-drawer::part(body) {
 | 
			
		||||
        max-width: var(--ha-bottom-sheet-max-width);
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        border-top-left-radius: var(
 | 
			
		||||
          --ha-bottom-sheet-border-radius,
 | 
			
		||||
          var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
 | 
			
		||||
        );
 | 
			
		||||
        border-top-right-radius: var(
 | 
			
		||||
          --ha-bottom-sheet-border-radius,
 | 
			
		||||
          var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
 | 
			
		||||
        );
 | 
			
		||||
        background-color: var(
 | 
			
		||||
          --ha-bottom-sheet-surface-background,
 | 
			
		||||
          var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
 | 
			
		||||
        );
 | 
			
		||||
        padding: var(
 | 
			
		||||
          --ha-bottom-sheet-padding,
 | 
			
		||||
          0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
 | 
			
		||||
            var(--safe-area-inset-left)
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      :host([flexcontent]) wa-drawer::part(body) {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
      }
 | 
			
		||||
      :host([flexcontent]) .body {
 | 
			
		||||
        flex: 1;
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        padding: var(
 | 
			
		||||
          --ha-bottom-sheet-padding,
 | 
			
		||||
          0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
 | 
			
		||||
            var(--safe-area-inset-left)
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    `,
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
 
 | 
			
		||||
@@ -31,6 +31,9 @@ export class HaButtonToggleGroup extends LitElement {
 | 
			
		||||
  @property({ type: Boolean, reflect: true, attribute: "no-wrap" })
 | 
			
		||||
  public nowrap = false;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean, reflect: true, attribute: "full-width" })
 | 
			
		||||
  public fullWidth = false;
 | 
			
		||||
 | 
			
		||||
  @property() public variant:
 | 
			
		||||
    | "brand"
 | 
			
		||||
    | "neutral"
 | 
			
		||||
@@ -38,6 +41,13 @@ export class HaButtonToggleGroup extends LitElement {
 | 
			
		||||
    | "warning"
 | 
			
		||||
    | "danger" = "brand";
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "active-variant" }) public activeVariant?:
 | 
			
		||||
    | "brand"
 | 
			
		||||
    | "neutral"
 | 
			
		||||
    | "success"
 | 
			
		||||
    | "warning"
 | 
			
		||||
    | "danger";
 | 
			
		||||
 | 
			
		||||
  protected render(): TemplateResult {
 | 
			
		||||
    return html`
 | 
			
		||||
      <wa-button-group childSelector="ha-button">
 | 
			
		||||
@@ -46,7 +56,9 @@ export class HaButtonToggleGroup extends LitElement {
 | 
			
		||||
            html`<ha-button
 | 
			
		||||
              iconTag="ha-svg-icon"
 | 
			
		||||
              class="icon"
 | 
			
		||||
              .variant=${this.variant}
 | 
			
		||||
              .variant=${this.active !== button.value || !this.activeVariant
 | 
			
		||||
                ? this.variant
 | 
			
		||||
                : this.activeVariant}
 | 
			
		||||
              .size=${this.size}
 | 
			
		||||
              .value=${button.value}
 | 
			
		||||
              @click=${this._handleClick}
 | 
			
		||||
@@ -78,6 +90,19 @@ export class HaButtonToggleGroup extends LitElement {
 | 
			
		||||
    :host([no-wrap]) wa-button-group::part(base) {
 | 
			
		||||
      flex-wrap: nowrap;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    wa-button-group {
 | 
			
		||||
      padding: var(--ha-button-toggle-group-padding);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :host([full-width]) wa-button-group,
 | 
			
		||||
    :host([full-width]) wa-button-group::part(base) {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :host([full-width]) ha-button {
 | 
			
		||||
      flex: 1;
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -59,6 +59,7 @@ export class HaButton extends Button {
 | 
			
		||||
          line-height: 1;
 | 
			
		||||
 | 
			
		||||
          transition: background-color 0.15s ease-in-out;
 | 
			
		||||
          text-wrap: wrap;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        :host([size="small"]) .button {
 | 
			
		||||
 
 | 
			
		||||
@@ -44,26 +44,26 @@ export class HaCard extends LitElement {
 | 
			
		||||
      font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
 | 
			
		||||
      letter-spacing: -0.012em;
 | 
			
		||||
      line-height: var(--ha-line-height-expanded);
 | 
			
		||||
      padding: 12px 16px 16px;
 | 
			
		||||
      padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
 | 
			
		||||
      display: block;
 | 
			
		||||
      margin-block-start: 0px;
 | 
			
		||||
      margin-block-end: 0px;
 | 
			
		||||
      margin-block-start: var(--ha-space-0);
 | 
			
		||||
      margin-block-end: var(--ha-space-0);
 | 
			
		||||
      font-weight: var(--ha-font-weight-normal);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :host ::slotted(.card-content:not(:first-child)),
 | 
			
		||||
    slot:not(:first-child)::slotted(.card-content) {
 | 
			
		||||
      padding-top: 0px;
 | 
			
		||||
      margin-top: -8px;
 | 
			
		||||
      padding-top: var(--ha-space-0);
 | 
			
		||||
      margin-top: calc(var(--ha-space-2) * -1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :host ::slotted(.card-content) {
 | 
			
		||||
      padding: 16px;
 | 
			
		||||
      padding: var(--ha-space-4);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    :host ::slotted(.card-actions) {
 | 
			
		||||
      border-top: 1px solid var(--divider-color, #e8e8e8);
 | 
			
		||||
      padding: 8px;
 | 
			
		||||
      padding: var(--ha-space-2);
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,9 @@ export class HaDialogHeader extends LitElement {
 | 
			
		||||
  @property({ type: String, attribute: "subtitle-position" })
 | 
			
		||||
  public subtitlePosition: "above" | "below" = "below";
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean, reflect: true, attribute: "show-border" })
 | 
			
		||||
  public showBorder = false;
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    const titleSlot = html`<div class="header-title">
 | 
			
		||||
      <slot name="title"></slot>
 | 
			
		||||
 
 | 
			
		||||
@@ -248,7 +248,7 @@ export class HaFilterDevices extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
        search-input-outlined {
 | 
			
		||||
          display: block;
 | 
			
		||||
          padding: 0 8px;
 | 
			
		||||
          padding: var(--ha-space-1) var(--ha-space-2) 0;
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
 
 | 
			
		||||
@@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
        search-input-outlined {
 | 
			
		||||
          display: block;
 | 
			
		||||
          padding: 0 8px;
 | 
			
		||||
          padding: var(--ha-space-1) var(--ha-space-2) 0;
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
 
 | 
			
		||||
@@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
        search-input-outlined {
 | 
			
		||||
          display: block;
 | 
			
		||||
          padding: 0 8px;
 | 
			
		||||
          padding: var(--ha-space-1) var(--ha-space-2) 0;
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
 
 | 
			
		||||
@@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
        search-input-outlined {
 | 
			
		||||
          display: block;
 | 
			
		||||
          padding: 0 8px;
 | 
			
		||||
          padding: var(--ha-space-1) var(--ha-space-2) 0;
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
 
 | 
			
		||||
@@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
 | 
			
		||||
        }
 | 
			
		||||
        search-input-outlined {
 | 
			
		||||
          display: block;
 | 
			
		||||
          padding: 0 8px;
 | 
			
		||||
          padding: var(--ha-space-1) var(--ha-space-2) 0;
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
 
 | 
			
		||||
@@ -148,7 +148,7 @@ export class HaForm extends LitElement implements HaFormElement {
 | 
			
		||||
                  .value=${getValue(this.data, item)}
 | 
			
		||||
                  .label=${this._computeLabel(item, this.data)}
 | 
			
		||||
                  .disabled=${item.disabled || this.disabled || false}
 | 
			
		||||
                  .placeholder=${item.required ? "" : item.default}
 | 
			
		||||
                  .placeholder=${item.required ? undefined : item.default}
 | 
			
		||||
                  .helper=${this._computeHelper(item)}
 | 
			
		||||
                  .localizeValue=${this.localizeValue}
 | 
			
		||||
                  .required=${item.required || false}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,14 @@
 | 
			
		||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
 | 
			
		||||
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
 | 
			
		||||
import "@home-assistant/webawesome/dist/components/popover/popover";
 | 
			
		||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
 | 
			
		||||
import { mdiPlaylistPlus } from "@mdi/js";
 | 
			
		||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import { ifDefined } from "lit/directives/if-defined";
 | 
			
		||||
import { tinykeys } from "tinykeys";
 | 
			
		||||
import { fireEvent } from "../common/dom/fire_event";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
import "./ha-bottom-sheet";
 | 
			
		||||
import "./ha-button";
 | 
			
		||||
import "./ha-combo-box-item";
 | 
			
		||||
import "./ha-icon-button";
 | 
			
		||||
import "./ha-input-helper-text";
 | 
			
		||||
@@ -15,12 +19,12 @@ import type {
 | 
			
		||||
  PickerComboBoxSearchFn,
 | 
			
		||||
} from "./ha-picker-combo-box";
 | 
			
		||||
import "./ha-picker-field";
 | 
			
		||||
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
 | 
			
		||||
import type { PickerValueRenderer } from "./ha-picker-field";
 | 
			
		||||
import "./ha-svg-icon";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-generic-picker")
 | 
			
		||||
export class HaGenericPicker extends LitElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
  @property({ attribute: false }) public hass?: HomeAssistant;
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line lit/no-native-attributes
 | 
			
		||||
  @property({ type: Boolean }) public autofocus = false;
 | 
			
		||||
@@ -53,7 +57,7 @@ export class HaGenericPicker extends LitElement {
 | 
			
		||||
  public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false })
 | 
			
		||||
  public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
 | 
			
		||||
  public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false })
 | 
			
		||||
  public valueRenderer?: PickerValueRenderer;
 | 
			
		||||
@@ -64,59 +68,142 @@ export class HaGenericPicker extends LitElement {
 | 
			
		||||
  @property({ attribute: "not-found-label", type: String })
 | 
			
		||||
  public notFoundLabel?: string;
 | 
			
		||||
 | 
			
		||||
  @query("ha-picker-field") private _field?: HaPickerField;
 | 
			
		||||
  @property({ attribute: "popover-placement" })
 | 
			
		||||
  public popoverPlacement:
 | 
			
		||||
    | "bottom"
 | 
			
		||||
    | "top"
 | 
			
		||||
    | "left"
 | 
			
		||||
    | "right"
 | 
			
		||||
    | "top-start"
 | 
			
		||||
    | "top-end"
 | 
			
		||||
    | "right-start"
 | 
			
		||||
    | "right-end"
 | 
			
		||||
    | "bottom-start"
 | 
			
		||||
    | "bottom-end"
 | 
			
		||||
    | "left-start"
 | 
			
		||||
    | "left-end" = "bottom-start";
 | 
			
		||||
 | 
			
		||||
  /** If set picker shows an add button instead of textbox when value isn't set */
 | 
			
		||||
  @property({ attribute: "add-button-label" }) public addButtonLabel?: string;
 | 
			
		||||
 | 
			
		||||
  @query(".container") private _containerElement?: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
  @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
 | 
			
		||||
 | 
			
		||||
  @state() private _opened = false;
 | 
			
		||||
 | 
			
		||||
  @state() private _pickerWrapperOpen = false;
 | 
			
		||||
 | 
			
		||||
  @state() private _popoverWidth = 0;
 | 
			
		||||
 | 
			
		||||
  @state() private _openedNarrow = false;
 | 
			
		||||
 | 
			
		||||
  private _narrow = false;
 | 
			
		||||
 | 
			
		||||
  // helper to set new value after closing picker, to avoid flicker
 | 
			
		||||
  private _newValue?: string;
 | 
			
		||||
 | 
			
		||||
  private _unsubscribeTinyKeys?: () => void;
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    return html`
 | 
			
		||||
      ${this.label
 | 
			
		||||
        ? html`<label ?disabled=${this.disabled}>${this.label}</label>`
 | 
			
		||||
        : nothing}
 | 
			
		||||
      <div class="container">
 | 
			
		||||
        ${!this._opened
 | 
			
		||||
        <div id="picker">
 | 
			
		||||
          <slot name="field">
 | 
			
		||||
            ${this.addButtonLabel && !this.value
 | 
			
		||||
              ? html`<ha-button
 | 
			
		||||
                  size="small"
 | 
			
		||||
                  appearance="filled"
 | 
			
		||||
                  @click=${this.open}
 | 
			
		||||
                  .disabled=${this.disabled}
 | 
			
		||||
                >
 | 
			
		||||
                  <ha-svg-icon
 | 
			
		||||
                    .path=${mdiPlaylistPlus}
 | 
			
		||||
                    slot="start"
 | 
			
		||||
                  ></ha-svg-icon>
 | 
			
		||||
                  ${this.addButtonLabel}
 | 
			
		||||
                </ha-button>`
 | 
			
		||||
              : html`<ha-picker-field
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  class=${this._opened ? "opened" : ""}
 | 
			
		||||
                  compact
 | 
			
		||||
                  aria-label=${ifDefined(this.label)}
 | 
			
		||||
                  @click=${this.open}
 | 
			
		||||
                  @clear=${this._clear}
 | 
			
		||||
                  .placeholder=${this.placeholder}
 | 
			
		||||
                  .value=${this.value}
 | 
			
		||||
                  .required=${this.required}
 | 
			
		||||
                  .disabled=${this.disabled}
 | 
			
		||||
                  .hideClearIcon=${this.hideClearIcon}
 | 
			
		||||
                  .valueRenderer=${this.valueRenderer}
 | 
			
		||||
                >
 | 
			
		||||
                </ha-picker-field>`}
 | 
			
		||||
          </slot>
 | 
			
		||||
        </div>
 | 
			
		||||
        ${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
 | 
			
		||||
          ? html`
 | 
			
		||||
              <ha-picker-field
 | 
			
		||||
                id="picker"
 | 
			
		||||
                type="button"
 | 
			
		||||
                compact
 | 
			
		||||
                aria-label=${ifDefined(this.label)}
 | 
			
		||||
                @click=${this.open}
 | 
			
		||||
                @clear=${this._clear}
 | 
			
		||||
                .placeholder=${this.placeholder}
 | 
			
		||||
                .value=${this.value}
 | 
			
		||||
                .required=${this.required}
 | 
			
		||||
                .disabled=${this.disabled}
 | 
			
		||||
                .hideClearIcon=${this.hideClearIcon}
 | 
			
		||||
                .valueRenderer=${this.valueRenderer}
 | 
			
		||||
              <wa-popover
 | 
			
		||||
                .open=${this._pickerWrapperOpen}
 | 
			
		||||
                style="--body-width: ${this._popoverWidth}px;"
 | 
			
		||||
                without-arrow
 | 
			
		||||
                distance="-4"
 | 
			
		||||
                .placement=${this.popoverPlacement}
 | 
			
		||||
                for="picker"
 | 
			
		||||
                auto-size="vertical"
 | 
			
		||||
                auto-size-padding="16"
 | 
			
		||||
                @wa-after-show=${this._dialogOpened}
 | 
			
		||||
                @wa-after-hide=${this._hidePicker}
 | 
			
		||||
                trap-focus
 | 
			
		||||
                role="dialog"
 | 
			
		||||
                aria-modal="true"
 | 
			
		||||
                aria-label=${this.label || "Select option"}
 | 
			
		||||
              >
 | 
			
		||||
              </ha-picker-field>
 | 
			
		||||
                ${this._renderComboBox()}
 | 
			
		||||
              </wa-popover>
 | 
			
		||||
            `
 | 
			
		||||
          : html`
 | 
			
		||||
              <ha-picker-combo-box
 | 
			
		||||
                .hass=${this.hass}
 | 
			
		||||
                .autofocus=${this.autofocus}
 | 
			
		||||
                .allowCustomValue=${this.allowCustomValue}
 | 
			
		||||
                .label=${this.searchLabel ??
 | 
			
		||||
                this.hass.localize("ui.common.search")}
 | 
			
		||||
                .value=${this.value}
 | 
			
		||||
                hide-clear-icon
 | 
			
		||||
                @opened-changed=${this._openedChanged}
 | 
			
		||||
                @value-changed=${this._valueChanged}
 | 
			
		||||
                .rowRenderer=${this.rowRenderer}
 | 
			
		||||
                .notFoundLabel=${this.notFoundLabel}
 | 
			
		||||
                .getItems=${this.getItems}
 | 
			
		||||
                .getAdditionalItems=${this.getAdditionalItems}
 | 
			
		||||
                .searchFn=${this.searchFn}
 | 
			
		||||
              ></ha-picker-combo-box>
 | 
			
		||||
            `}
 | 
			
		||||
          : this._pickerWrapperOpen || this._opened
 | 
			
		||||
            ? html`<ha-bottom-sheet
 | 
			
		||||
                flexcontent
 | 
			
		||||
                .open=${this._pickerWrapperOpen}
 | 
			
		||||
                @wa-after-show=${this._dialogOpened}
 | 
			
		||||
                @closed=${this._hidePicker}
 | 
			
		||||
                role="dialog"
 | 
			
		||||
                aria-modal="true"
 | 
			
		||||
                aria-label=${this.label || "Select option"}
 | 
			
		||||
              >
 | 
			
		||||
                ${this._renderComboBox(true)}
 | 
			
		||||
              </ha-bottom-sheet>`
 | 
			
		||||
            : nothing}
 | 
			
		||||
      </div>
 | 
			
		||||
      ${this._renderHelper()}
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _renderComboBox(dialogMode = false) {
 | 
			
		||||
    if (!this._opened) {
 | 
			
		||||
      return nothing;
 | 
			
		||||
    }
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-picker-combo-box
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .allowCustomValue=${this.allowCustomValue}
 | 
			
		||||
        .label=${this.searchLabel ??
 | 
			
		||||
        (this.hass?.localize("ui.common.search") || "Search")}
 | 
			
		||||
        .value=${this.value}
 | 
			
		||||
        @value-changed=${this._valueChanged}
 | 
			
		||||
        .rowRenderer=${this.rowRenderer}
 | 
			
		||||
        .notFoundLabel=${this.notFoundLabel}
 | 
			
		||||
        .getItems=${this.getItems}
 | 
			
		||||
        .getAdditionalItems=${this.getAdditionalItems}
 | 
			
		||||
        .searchFn=${this.searchFn}
 | 
			
		||||
        .mode=${dialogMode ? "dialog" : "popover"}
 | 
			
		||||
      ></ha-picker-combo-box>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _renderHelper() {
 | 
			
		||||
    return this.helper
 | 
			
		||||
      ? html`<ha-input-helper-text .disabled=${this.disabled}
 | 
			
		||||
@@ -125,13 +212,33 @@ export class HaGenericPicker extends LitElement {
 | 
			
		||||
      : nothing;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _dialogOpened = () => {
 | 
			
		||||
    this._opened = true;
 | 
			
		||||
    requestAnimationFrame(() => {
 | 
			
		||||
      this._comboBox?.focus();
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _hidePicker(ev) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    if (this._newValue) {
 | 
			
		||||
      fireEvent(this, "value-changed", { value: this._newValue });
 | 
			
		||||
      this._newValue = undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this._opened = false;
 | 
			
		||||
    this._pickerWrapperOpen = false;
 | 
			
		||||
    this._unsubscribeTinyKeys?.();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _valueChanged(ev: CustomEvent) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    const value = ev.detail.value;
 | 
			
		||||
    if (!value) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    fireEvent(this, "value-changed", { value });
 | 
			
		||||
    this._pickerWrapperOpen = false;
 | 
			
		||||
    this._newValue = value;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _clear(e) {
 | 
			
		||||
@@ -144,25 +251,45 @@ export class HaGenericPicker extends LitElement {
 | 
			
		||||
    fireEvent(this, "value-changed", { value });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async open() {
 | 
			
		||||
  public async open(ev?: Event) {
 | 
			
		||||
    ev?.stopPropagation();
 | 
			
		||||
    if (this.disabled) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this._opened = true;
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    this._comboBox?.focus();
 | 
			
		||||
    this._comboBox?.open();
 | 
			
		||||
    this._openedNarrow = this._narrow;
 | 
			
		||||
    this._popoverWidth = this._containerElement?.offsetWidth || 250;
 | 
			
		||||
    this._pickerWrapperOpen = true;
 | 
			
		||||
    this._unsubscribeTinyKeys = tinykeys(this, {
 | 
			
		||||
      Escape: this._handleEscClose,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
 | 
			
		||||
    const opened = ev.detail.value;
 | 
			
		||||
    if (this._opened && !opened) {
 | 
			
		||||
      this._opened = false;
 | 
			
		||||
      await this.updateComplete;
 | 
			
		||||
      this._field?.focus();
 | 
			
		||||
    }
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    super.connectedCallback();
 | 
			
		||||
    this._handleResize();
 | 
			
		||||
    window.addEventListener("resize", this._handleResize);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public disconnectedCallback() {
 | 
			
		||||
    super.disconnectedCallback();
 | 
			
		||||
    window.removeEventListener("resize", this._handleResize);
 | 
			
		||||
    this._unsubscribeTinyKeys?.();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _handleResize = () => {
 | 
			
		||||
    this._narrow =
 | 
			
		||||
      window.matchMedia("(max-width: 870px)").matches ||
 | 
			
		||||
      window.matchMedia("(max-height: 500px)").matches;
 | 
			
		||||
 | 
			
		||||
    if (!this._openedNarrow && this._pickerWrapperOpen) {
 | 
			
		||||
      this._popoverWidth = this._containerElement?.offsetWidth || 250;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _handleEscClose = (ev: KeyboardEvent) => {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static get styles(): CSSResultGroup {
 | 
			
		||||
    return [
 | 
			
		||||
      css`
 | 
			
		||||
@@ -181,6 +308,44 @@ export class HaGenericPicker extends LitElement {
 | 
			
		||||
          display: block;
 | 
			
		||||
          margin: var(--ha-space-2) 0 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        wa-popover {
 | 
			
		||||
          --wa-space-l: var(--ha-space-0);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        wa-popover::part(body) {
 | 
			
		||||
          width: max(var(--body-width), 250px);
 | 
			
		||||
          max-width: max(var(--body-width), 250px);
 | 
			
		||||
          max-height: 500px;
 | 
			
		||||
          height: 70vh;
 | 
			
		||||
          overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @media (max-height: 1000px) {
 | 
			
		||||
          wa-popover::part(body) {
 | 
			
		||||
            max-height: 400px;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @media (max-height: 1000px) {
 | 
			
		||||
          wa-popover::part(body) {
 | 
			
		||||
            max-height: 400px;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-bottom-sheet {
 | 
			
		||||
          --ha-bottom-sheet-height: 90vh;
 | 
			
		||||
          --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
 | 
			
		||||
          --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
 | 
			
		||||
          --ha-bottom-sheet-max-width: 600px;
 | 
			
		||||
          --ha-bottom-sheet-padding: var(--ha-space-0);
 | 
			
		||||
          --ha-bottom-sheet-surface-background: var(--card-background-color);
 | 
			
		||||
          --ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-picker-field.opened {
 | 
			
		||||
          --mdc-text-field-idle-line-color: var(--primary-color);
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,13 @@ import { mdiLabel, mdiPlus } from "@mdi/js";
 | 
			
		||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
 | 
			
		||||
import type { TemplateResult } from "lit";
 | 
			
		||||
import { LitElement, html } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import {
 | 
			
		||||
  customElement,
 | 
			
		||||
  property,
 | 
			
		||||
  query,
 | 
			
		||||
  queryAssignedElements,
 | 
			
		||||
  state,
 | 
			
		||||
} from "lit/decorators";
 | 
			
		||||
import memoizeOne from "memoize-one";
 | 
			
		||||
import { fireEvent } from "../common/dom/fire_event";
 | 
			
		||||
import type { LabelRegistryEntry } from "../data/label_registry";
 | 
			
		||||
@@ -84,6 +90,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
 | 
			
		||||
 | 
			
		||||
  @state() private _labels?: LabelRegistryEntry[];
 | 
			
		||||
 | 
			
		||||
  @queryAssignedElements({ flatten: true })
 | 
			
		||||
  private _slotNodes?: NodeListOf<HTMLElement>;
 | 
			
		||||
 | 
			
		||||
  @query("ha-generic-picker") private _picker?: HaGenericPicker;
 | 
			
		||||
 | 
			
		||||
  public async open() {
 | 
			
		||||
@@ -211,12 +220,14 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-generic-picker
 | 
			
		||||
        .disabled=${this.disabled}
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .autofocus=${this.autofocus}
 | 
			
		||||
        .label=${this.label}
 | 
			
		||||
        .notFoundLabel=${this.hass.localize(
 | 
			
		||||
          "ui.components.label-picker.no_match"
 | 
			
		||||
        )}
 | 
			
		||||
        .addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
 | 
			
		||||
        .placeholder=${placeholder}
 | 
			
		||||
        .value=${this.value}
 | 
			
		||||
        .getItems=${this._getItems}
 | 
			
		||||
@@ -224,6 +235,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
 | 
			
		||||
        .valueRenderer=${valueRenderer}
 | 
			
		||||
        @value-changed=${this._valueChanged}
 | 
			
		||||
      >
 | 
			
		||||
        <slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>
 | 
			
		||||
      </ha-generic-picker>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { mdiPlaylistPlus } from "@mdi/js";
 | 
			
		||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
 | 
			
		||||
import type { TemplateResult } from "lit";
 | 
			
		||||
import { LitElement, css, html, nothing } from "lit";
 | 
			
		||||
@@ -123,36 +124,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
 | 
			
		||||
    );
 | 
			
		||||
    return html`
 | 
			
		||||
      ${this.label ? html`<label>${this.label}</label>` : nothing}
 | 
			
		||||
      ${labels?.length
 | 
			
		||||
        ? html`<ha-chip-set>
 | 
			
		||||
            ${repeat(
 | 
			
		||||
              labels,
 | 
			
		||||
              (label) => label?.label_id,
 | 
			
		||||
              (label) => {
 | 
			
		||||
                const color = label?.color
 | 
			
		||||
                  ? computeCssColor(label.color)
 | 
			
		||||
                  : undefined;
 | 
			
		||||
                return html`
 | 
			
		||||
                  <ha-input-chip
 | 
			
		||||
                    .item=${label}
 | 
			
		||||
                    @remove=${this._removeItem}
 | 
			
		||||
                    @click=${this._openDetail}
 | 
			
		||||
                    .label=${label?.name}
 | 
			
		||||
                    selected
 | 
			
		||||
                    style=${color ? `--color: ${color}` : ""}
 | 
			
		||||
                  >
 | 
			
		||||
                    ${label?.icon
 | 
			
		||||
                      ? html`<ha-icon
 | 
			
		||||
                          slot="icon"
 | 
			
		||||
                          .icon=${label.icon}
 | 
			
		||||
                        ></ha-icon>`
 | 
			
		||||
                      : nothing}
 | 
			
		||||
                  </ha-input-chip>
 | 
			
		||||
                `;
 | 
			
		||||
              }
 | 
			
		||||
            )}
 | 
			
		||||
          </ha-chip-set>`
 | 
			
		||||
        : nothing}
 | 
			
		||||
      <ha-label-picker
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .helper=${this.helper}
 | 
			
		||||
@@ -162,6 +133,47 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
 | 
			
		||||
        .excludeLabels=${this.value}
 | 
			
		||||
        @value-changed=${this._labelChanged}
 | 
			
		||||
      >
 | 
			
		||||
        <ha-chip-set>
 | 
			
		||||
          ${labels?.length
 | 
			
		||||
            ? repeat(
 | 
			
		||||
                labels,
 | 
			
		||||
                (label) => label?.label_id,
 | 
			
		||||
                (label) => {
 | 
			
		||||
                  const color = label?.color
 | 
			
		||||
                    ? computeCssColor(label.color)
 | 
			
		||||
                    : undefined;
 | 
			
		||||
                  return html`
 | 
			
		||||
                    <ha-input-chip
 | 
			
		||||
                      .item=${label}
 | 
			
		||||
                      @remove=${this._removeItem}
 | 
			
		||||
                      @click=${this._openDetail}
 | 
			
		||||
                      .disabled=${this.disabled}
 | 
			
		||||
                      .label=${label?.name}
 | 
			
		||||
                      selected
 | 
			
		||||
                      style=${color ? `--color: ${color}` : ""}
 | 
			
		||||
                    >
 | 
			
		||||
                      ${label?.icon
 | 
			
		||||
                        ? html`<ha-icon
 | 
			
		||||
                            slot="icon"
 | 
			
		||||
                            .icon=${label.icon}
 | 
			
		||||
                          ></ha-icon>`
 | 
			
		||||
                        : nothing}
 | 
			
		||||
                    </ha-input-chip>
 | 
			
		||||
                  `;
 | 
			
		||||
                }
 | 
			
		||||
              )
 | 
			
		||||
            : nothing}
 | 
			
		||||
          <ha-button
 | 
			
		||||
            id="picker"
 | 
			
		||||
            size="small"
 | 
			
		||||
            appearance="filled"
 | 
			
		||||
            @click=${this._openPicker}
 | 
			
		||||
            .disabled=${this.disabled}
 | 
			
		||||
          >
 | 
			
		||||
            <ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
 | 
			
		||||
            ${this.hass.localize("ui.components.label-picker.add")}
 | 
			
		||||
          </ha-button>
 | 
			
		||||
        </ha-chip-set>
 | 
			
		||||
      </ha-label-picker>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
@@ -203,9 +215,25 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
 | 
			
		||||
    }, 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _openPicker(ev: Event) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    this.labelPicker.open();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    ha-chip-set {
 | 
			
		||||
      margin-bottom: 8px;
 | 
			
		||||
      background-color: var(--mdc-text-field-fill-color);
 | 
			
		||||
      border-bottom: 1px solid var(--ha-color-border-neutral-normal);
 | 
			
		||||
      border-top-right-radius: var(--ha-border-radius-sm);
 | 
			
		||||
      border-top-left-radius: var(--ha-border-radius-sm);
 | 
			
		||||
      padding: var(--ha-space-3);
 | 
			
		||||
    }
 | 
			
		||||
    .placeholder {
 | 
			
		||||
      color: var(--mdc-text-field-label-ink-color);
 | 
			
		||||
      display: flex;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
      height: var(--ha-space-8);
 | 
			
		||||
    }
 | 
			
		||||
    ha-input-chip {
 | 
			
		||||
      --md-input-chip-selected-container-color: var(--color, var(--grey-color));
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +1,58 @@
 | 
			
		||||
import type { PropertyValues } from "lit";
 | 
			
		||||
import { css, html, LitElement } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import { customElement, property, state } from "lit/decorators";
 | 
			
		||||
import memoizeOne from "memoize-one";
 | 
			
		||||
import { fireEvent } from "../common/dom/fire_event";
 | 
			
		||||
import { stopPropagation } from "../common/dom/stop_propagation";
 | 
			
		||||
import { formatLanguageCode } from "../common/language/format_language";
 | 
			
		||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
 | 
			
		||||
import type { FrontendLocaleData } from "../data/translation";
 | 
			
		||||
import { translationMetadata } from "../resources/translations-metadata";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
 | 
			
		||||
import "./ha-generic-picker";
 | 
			
		||||
import "./ha-list-item";
 | 
			
		||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
 | 
			
		||||
import "./ha-select";
 | 
			
		||||
import type { HaSelect } from "./ha-select";
 | 
			
		||||
 | 
			
		||||
export const getLanguageOptions = (
 | 
			
		||||
  languages: string[],
 | 
			
		||||
  nativeName: boolean,
 | 
			
		||||
  noSort: boolean,
 | 
			
		||||
  locale?: FrontendLocaleData
 | 
			
		||||
) => {
 | 
			
		||||
  let options: { label: string; value: string }[] = [];
 | 
			
		||||
): PickerComboBoxItem[] => {
 | 
			
		||||
  let options: PickerComboBoxItem[] = [];
 | 
			
		||||
 | 
			
		||||
  if (nativeName) {
 | 
			
		||||
    const translations = translationMetadata.translations;
 | 
			
		||||
    options = languages.map((lang) => {
 | 
			
		||||
      let label = translations[lang]?.nativeName;
 | 
			
		||||
      if (!label) {
 | 
			
		||||
      let primary = translations[lang]?.nativeName;
 | 
			
		||||
      if (!primary) {
 | 
			
		||||
        try {
 | 
			
		||||
          // this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user
 | 
			
		||||
          label = new Intl.DisplayNames(lang, {
 | 
			
		||||
          primary = new Intl.DisplayNames(lang, {
 | 
			
		||||
            type: "language",
 | 
			
		||||
            fallback: "code",
 | 
			
		||||
          }).of(lang)!;
 | 
			
		||||
        } catch (_err) {
 | 
			
		||||
          label = lang;
 | 
			
		||||
          primary = lang;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return {
 | 
			
		||||
        value: lang,
 | 
			
		||||
        label,
 | 
			
		||||
        id: lang,
 | 
			
		||||
        primary,
 | 
			
		||||
        search_labels: [primary],
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
  } else if (locale) {
 | 
			
		||||
    options = languages.map((lang) => ({
 | 
			
		||||
      value: lang,
 | 
			
		||||
      label: formatLanguageCode(lang, locale),
 | 
			
		||||
      id: lang,
 | 
			
		||||
      primary: formatLanguageCode(lang, locale),
 | 
			
		||||
      search_labels: [formatLanguageCode(lang, locale)],
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!noSort && locale) {
 | 
			
		||||
    options.sort((a, b) =>
 | 
			
		||||
      caseInsensitiveStringCompare(a.label, b.label, locale.language)
 | 
			
		||||
      caseInsensitiveStringCompare(a.primary, b.primary, locale.language)
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  return options;
 | 
			
		||||
@@ -80,115 +82,69 @@ export class HaLanguagePicker extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @state() _defaultLanguages: string[] = [];
 | 
			
		||||
 | 
			
		||||
  @query("ha-select") private _select!: HaSelect;
 | 
			
		||||
 | 
			
		||||
  protected firstUpdated(changedProps: PropertyValues) {
 | 
			
		||||
    super.firstUpdated(changedProps);
 | 
			
		||||
    this._computeDefaultLanguageOptions();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected updated(changedProperties: PropertyValues) {
 | 
			
		||||
    super.updated(changedProperties);
 | 
			
		||||
 | 
			
		||||
    const localeChanged =
 | 
			
		||||
      changedProperties.has("hass") &&
 | 
			
		||||
      this.hass &&
 | 
			
		||||
      changedProperties.get("hass") &&
 | 
			
		||||
      changedProperties.get("hass").locale.language !==
 | 
			
		||||
        this.hass.locale.language;
 | 
			
		||||
    if (
 | 
			
		||||
      changedProperties.has("languages") ||
 | 
			
		||||
      changedProperties.has("value") ||
 | 
			
		||||
      localeChanged
 | 
			
		||||
    ) {
 | 
			
		||||
      this._select.layoutOptions();
 | 
			
		||||
      if (!this.disabled && this._select.value !== this.value) {
 | 
			
		||||
        fireEvent(this, "value-changed", { value: this._select.value });
 | 
			
		||||
      }
 | 
			
		||||
      if (!this.value) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const languageOptions = this._getLanguagesOptions(
 | 
			
		||||
        this.languages ?? this._defaultLanguages,
 | 
			
		||||
        this.nativeName,
 | 
			
		||||
        this.noSort,
 | 
			
		||||
        this.hass?.locale
 | 
			
		||||
      );
 | 
			
		||||
      const selectedItemIndex = languageOptions.findIndex(
 | 
			
		||||
        (option) => option.value === this.value
 | 
			
		||||
      );
 | 
			
		||||
      if (selectedItemIndex === -1) {
 | 
			
		||||
        this.value = undefined;
 | 
			
		||||
      }
 | 
			
		||||
      if (localeChanged) {
 | 
			
		||||
        this._select.select(selectedItemIndex);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _getLanguagesOptions = memoizeOne(getLanguageOptions);
 | 
			
		||||
 | 
			
		||||
  private _computeDefaultLanguageOptions() {
 | 
			
		||||
    this._defaultLanguages = Object.keys(translationMetadata.translations);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    const languageOptions = this._getLanguagesOptions(
 | 
			
		||||
  private _getItems = () =>
 | 
			
		||||
    this._getLanguagesOptions(
 | 
			
		||||
      this.languages ?? this._defaultLanguages,
 | 
			
		||||
      this.nativeName,
 | 
			
		||||
      this.noSort,
 | 
			
		||||
      this.hass?.locale
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  private _valueRenderer = (value) => {
 | 
			
		||||
    const language = this._getItems().find(
 | 
			
		||||
      (lang) => lang.id === value
 | 
			
		||||
    )?.primary;
 | 
			
		||||
    return html`<span slot="headline">${language ?? value}</span> `;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    const value =
 | 
			
		||||
      this.value ??
 | 
			
		||||
      (this.required && !this.disabled
 | 
			
		||||
        ? languageOptions[0]?.value
 | 
			
		||||
        : this.value);
 | 
			
		||||
      (this.required && !this.disabled ? this._getItems()[0].id : this.value);
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-select
 | 
			
		||||
        .label=${this.label ??
 | 
			
		||||
      <ha-generic-picker
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .autofocus=${this.autofocus}
 | 
			
		||||
        popover-placement="bottom-end"
 | 
			
		||||
        .notFoundLabel=${this.hass?.localize(
 | 
			
		||||
          "ui.components.language-picker.no_match"
 | 
			
		||||
        )}
 | 
			
		||||
        .placeholder=${this.label ??
 | 
			
		||||
        (this.hass?.localize("ui.components.language-picker.language") ||
 | 
			
		||||
          "Language")}
 | 
			
		||||
        .value=${value || ""}
 | 
			
		||||
        .required=${this.required}
 | 
			
		||||
        .value=${value}
 | 
			
		||||
        .valueRenderer=${this._valueRenderer}
 | 
			
		||||
        .disabled=${this.disabled}
 | 
			
		||||
        @selected=${this._changed}
 | 
			
		||||
        @closed=${stopPropagation}
 | 
			
		||||
        fixedMenuPosition
 | 
			
		||||
        naturalMenuWidth
 | 
			
		||||
        .inlineArrow=${this.inlineArrow}
 | 
			
		||||
      >
 | 
			
		||||
        ${languageOptions.length === 0
 | 
			
		||||
          ? html`<ha-list-item value=""
 | 
			
		||||
              >${this.hass?.localize(
 | 
			
		||||
                "ui.components.language-picker.no_languages"
 | 
			
		||||
              ) || "No languages"}</ha-list-item
 | 
			
		||||
            >`
 | 
			
		||||
          : languageOptions.map(
 | 
			
		||||
              (option) => html`
 | 
			
		||||
                <ha-list-item .value=${option.value}
 | 
			
		||||
                  >${option.label}</ha-list-item
 | 
			
		||||
                >
 | 
			
		||||
              `
 | 
			
		||||
            )}
 | 
			
		||||
      </ha-select>
 | 
			
		||||
        .getItems=${this._getItems}
 | 
			
		||||
        @value-changed=${this._changed}
 | 
			
		||||
        hide-clear-icon
 | 
			
		||||
      ></ha-generic-picker>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    ha-select {
 | 
			
		||||
    ha-generic-picker {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      min-width: 200px;
 | 
			
		||||
      display: block;
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
 | 
			
		||||
  private _changed(ev): void {
 | 
			
		||||
    const target = ev.target as HaSelect;
 | 
			
		||||
    if (this.disabled || target.value === "" || target.value === this.value) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.value = target.value;
 | 
			
		||||
  private _changed(ev: ValueChangedEvent<string>): void {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    this.value = ev.detail.value;
 | 
			
		||||
    fireEvent(this, "value-changed", { value: this.value });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -50,7 +50,7 @@ export class HaMarkdown extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
    ha-alert {
 | 
			
		||||
      display: block;
 | 
			
		||||
      margin: 4px 0;
 | 
			
		||||
      margin: var(--ha-space-1) 0;
 | 
			
		||||
    }
 | 
			
		||||
    a {
 | 
			
		||||
      color: var(--primary-color);
 | 
			
		||||
@@ -75,7 +75,7 @@ export class HaMarkdown extends LitElement {
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
    pre {
 | 
			
		||||
      padding: 16px;
 | 
			
		||||
      padding: var(--ha-space-4);
 | 
			
		||||
      overflow: auto;
 | 
			
		||||
      line-height: var(--ha-line-height-condensed);
 | 
			
		||||
      font-family: var(--ha-font-family-code);
 | 
			
		||||
@@ -95,7 +95,7 @@ export class HaMarkdown extends LitElement {
 | 
			
		||||
    hr {
 | 
			
		||||
      border-color: var(--divider-color);
 | 
			
		||||
      border-bottom: none;
 | 
			
		||||
      margin: 16px 0;
 | 
			
		||||
      margin: var(--ha-space-4) 0;
 | 
			
		||||
    }
 | 
			
		||||
  ` as CSSResultGroup;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,28 @@
 | 
			
		||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
 | 
			
		||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
 | 
			
		||||
import { mdiMagnify } from "@mdi/js";
 | 
			
		||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
 | 
			
		||||
import Fuse from "fuse.js";
 | 
			
		||||
import type { PropertyValues, TemplateResult } from "lit";
 | 
			
		||||
import { html, LitElement, nothing } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import { css, html, LitElement, nothing } from "lit";
 | 
			
		||||
import {
 | 
			
		||||
  customElement,
 | 
			
		||||
  eventOptions,
 | 
			
		||||
  property,
 | 
			
		||||
  query,
 | 
			
		||||
  state,
 | 
			
		||||
} from "lit/decorators";
 | 
			
		||||
import memoizeOne from "memoize-one";
 | 
			
		||||
import { tinykeys } from "tinykeys";
 | 
			
		||||
import { fireEvent } from "../common/dom/fire_event";
 | 
			
		||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
 | 
			
		||||
import type { LocalizeFunc } from "../common/translations/localize";
 | 
			
		||||
import { HaFuse } from "../resources/fuse";
 | 
			
		||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
 | 
			
		||||
import "./ha-combo-box";
 | 
			
		||||
import type { HaComboBox } from "./ha-combo-box";
 | 
			
		||||
import { haStyleScrollbar } from "../resources/styles";
 | 
			
		||||
import { loadVirtualizer } from "../resources/virtualizer";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
import "./ha-combo-box-item";
 | 
			
		||||
import "./ha-icon";
 | 
			
		||||
import "./ha-textfield";
 | 
			
		||||
import type { HaTextField } from "./ha-textfield";
 | 
			
		||||
 | 
			
		||||
export interface PickerComboBoxItem {
 | 
			
		||||
  id: string;
 | 
			
		||||
@@ -33,10 +42,13 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
 | 
			
		||||
 | 
			
		||||
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
 | 
			
		||||
 | 
			
		||||
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
 | 
			
		||||
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
 | 
			
		||||
  item
 | 
			
		||||
) => html`
 | 
			
		||||
  <ha-combo-box-item type="button" compact>
 | 
			
		||||
  <ha-combo-box-item
 | 
			
		||||
    .type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
 | 
			
		||||
    compact
 | 
			
		||||
  >
 | 
			
		||||
    ${item.icon
 | 
			
		||||
      ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
 | 
			
		||||
      : item.icon_path
 | 
			
		||||
@@ -57,7 +69,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
 | 
			
		||||
 | 
			
		||||
@customElement("ha-picker-combo-box")
 | 
			
		||||
export class HaPickerComboBox extends LitElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
  @property({ attribute: false }) public hass?: HomeAssistant;
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line lit/no-native-attributes
 | 
			
		||||
  @property({ type: Boolean }) public autofocus = false;
 | 
			
		||||
@@ -73,7 +85,7 @@ export class HaPickerComboBox extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property() public value?: string;
 | 
			
		||||
 | 
			
		||||
  @property() public helper?: string;
 | 
			
		||||
  @state() private _listScrolled = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false, type: Array })
 | 
			
		||||
  public getItems?: () => PickerComboBoxItem[];
 | 
			
		||||
@@ -82,10 +94,7 @@ export class HaPickerComboBox extends LitElement {
 | 
			
		||||
  public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false })
 | 
			
		||||
  public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "hide-clear-icon", type: Boolean })
 | 
			
		||||
  public hideClearIcon = false;
 | 
			
		||||
  public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "not-found-label", type: String })
 | 
			
		||||
  public notFoundLabel?: string;
 | 
			
		||||
@@ -93,33 +102,77 @@ export class HaPickerComboBox extends LitElement {
 | 
			
		||||
  @property({ attribute: false })
 | 
			
		||||
  public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
 | 
			
		||||
 | 
			
		||||
  @state() private _opened = false;
 | 
			
		||||
  @property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
 | 
			
		||||
 | 
			
		||||
  @query("ha-combo-box", true) public comboBox!: HaComboBox;
 | 
			
		||||
  @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
 | 
			
		||||
 | 
			
		||||
  public async open() {
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    await this.comboBox?.open();
 | 
			
		||||
  @query("ha-textfield") private _searchFieldElement?: HaTextField;
 | 
			
		||||
 | 
			
		||||
  @state() private _items: PickerComboBoxItemWithLabel[] = [];
 | 
			
		||||
 | 
			
		||||
  private _allItems: PickerComboBoxItemWithLabel[] = [];
 | 
			
		||||
 | 
			
		||||
  private _selectedItemIndex = -1;
 | 
			
		||||
 | 
			
		||||
  static shadowRootOptions = {
 | 
			
		||||
    ...LitElement.shadowRootOptions,
 | 
			
		||||
    delegatesFocus: true,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _removeKeyboardShortcuts?: () => void;
 | 
			
		||||
 | 
			
		||||
  protected firstUpdated() {
 | 
			
		||||
    this._registerKeyboardShortcuts();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async focus() {
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    await this.comboBox?.focus();
 | 
			
		||||
  public willUpdate() {
 | 
			
		||||
    if (!this.hasUpdated) {
 | 
			
		||||
      loadVirtualizer();
 | 
			
		||||
      this._allItems = this._getItems();
 | 
			
		||||
      this._items = this._allItems;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _initialItems = false;
 | 
			
		||||
  disconnectedCallback() {
 | 
			
		||||
    super.disconnectedCallback();
 | 
			
		||||
    this._removeKeyboardShortcuts?.();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _items: PickerComboBoxItemWithLabel[] = [];
 | 
			
		||||
  protected render() {
 | 
			
		||||
    return html`<ha-textfield
 | 
			
		||||
        .label=${this.label ??
 | 
			
		||||
        this.hass?.localize("ui.common.search") ??
 | 
			
		||||
        "Search"}
 | 
			
		||||
        @input=${this._filterChanged}
 | 
			
		||||
      ></ha-textfield>
 | 
			
		||||
      <lit-virtualizer
 | 
			
		||||
        @scroll=${this._onScrollList}
 | 
			
		||||
        tabindex="0"
 | 
			
		||||
        scroller
 | 
			
		||||
        .items=${this._items}
 | 
			
		||||
        .renderItem=${this._renderItem}
 | 
			
		||||
        style="min-height: 36px;"
 | 
			
		||||
        class=${this._listScrolled ? "scrolled" : ""}
 | 
			
		||||
        @focus=${this._focusList}
 | 
			
		||||
      >
 | 
			
		||||
      </lit-virtualizer> `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _defaultNotFoundItem = memoizeOne(
 | 
			
		||||
    (
 | 
			
		||||
      label: this["notFoundLabel"],
 | 
			
		||||
      localize: LocalizeFunc
 | 
			
		||||
      localize?: LocalizeFunc
 | 
			
		||||
    ): PickerComboBoxItemWithLabel => ({
 | 
			
		||||
      id: NO_MATCHING_ITEMS_FOUND_ID,
 | 
			
		||||
      primary: label || localize("ui.components.combo-box.no_match"),
 | 
			
		||||
      primary:
 | 
			
		||||
        label ||
 | 
			
		||||
        (localize && localize("ui.components.combo-box.no_match")) ||
 | 
			
		||||
        "No matching items found",
 | 
			
		||||
      icon_path: mdiMagnify,
 | 
			
		||||
      a11y_label: label || localize("ui.components.combo-box.no_match"),
 | 
			
		||||
      a11y_label:
 | 
			
		||||
        label ||
 | 
			
		||||
        (localize && localize("ui.components.combo-box.no_match")) ||
 | 
			
		||||
        "No matching items found",
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@@ -144,13 +197,13 @@ export class HaPickerComboBox extends LitElement {
 | 
			
		||||
        caseInsensitiveStringCompare(
 | 
			
		||||
          entityA.sorting_label!,
 | 
			
		||||
          entityB.sorting_label!,
 | 
			
		||||
          this.hass.locale.language
 | 
			
		||||
          this.hass?.locale.language ?? navigator.language
 | 
			
		||||
        )
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    if (!sortedItems.length) {
 | 
			
		||||
      sortedItems.push(
 | 
			
		||||
        this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize)
 | 
			
		||||
        this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -159,99 +212,73 @@ export class HaPickerComboBox extends LitElement {
 | 
			
		||||
    return sortedItems;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  protected shouldUpdate(changedProps: PropertyValues) {
 | 
			
		||||
    if (
 | 
			
		||||
      changedProps.has("value") ||
 | 
			
		||||
      changedProps.has("label") ||
 | 
			
		||||
      changedProps.has("disabled")
 | 
			
		||||
    ) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    return !(!changedProps.has("_opened") && this._opened);
 | 
			
		||||
  }
 | 
			
		||||
  private _renderItem = (item: PickerComboBoxItem, index: number) => {
 | 
			
		||||
    const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
 | 
			
		||||
    return html`<div
 | 
			
		||||
      id=${`list-item-${index}`}
 | 
			
		||||
      class="combo-box-row ${this._value === item.id ? "current-value" : ""}"
 | 
			
		||||
      .value=${item.id}
 | 
			
		||||
      .index=${index}
 | 
			
		||||
      @click=${this._valueSelected}
 | 
			
		||||
    >
 | 
			
		||||
      ${item.id === NO_MATCHING_ITEMS_FOUND_ID
 | 
			
		||||
        ? DEFAULT_ROW_RENDERER(item, index)
 | 
			
		||||
        : renderer(item, index)}
 | 
			
		||||
    </div>`;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  public willUpdate(changedProps: PropertyValues) {
 | 
			
		||||
    if (changedProps.has("_opened") && this._opened) {
 | 
			
		||||
      this._items = this._getItems();
 | 
			
		||||
      if (this._initialItems) {
 | 
			
		||||
        this.comboBox.filteredItems = this._items;
 | 
			
		||||
      }
 | 
			
		||||
      this._initialItems = true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected render(): TemplateResult {
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-combo-box
 | 
			
		||||
        item-id-path="id"
 | 
			
		||||
        item-value-path="id"
 | 
			
		||||
        item-label-path="a11y_label"
 | 
			
		||||
        clear-initial-value
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .value=${this._value}
 | 
			
		||||
        .label=${this.label}
 | 
			
		||||
        .helper=${this.helper}
 | 
			
		||||
        .allowCustomValue=${this.allowCustomValue}
 | 
			
		||||
        .filteredItems=${this._items}
 | 
			
		||||
        .renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER}
 | 
			
		||||
        .required=${this.required}
 | 
			
		||||
        .disabled=${this.disabled}
 | 
			
		||||
        .hideClearIcon=${this.hideClearIcon}
 | 
			
		||||
        @opened-changed=${this._openedChanged}
 | 
			
		||||
        @value-changed=${this._valueChanged}
 | 
			
		||||
        @filter-changed=${this._filterChanged}
 | 
			
		||||
      >
 | 
			
		||||
      </ha-combo-box>
 | 
			
		||||
    `;
 | 
			
		||||
  @eventOptions({ passive: true })
 | 
			
		||||
  private _onScrollList(ev) {
 | 
			
		||||
    const top = ev.target.scrollTop ?? 0;
 | 
			
		||||
    this._listScrolled = top > 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private get _value() {
 | 
			
		||||
    return this.value || "";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _openedChanged(ev: ValueChangedEvent<boolean>) {
 | 
			
		||||
  private _valueSelected = (ev: Event) => {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    if (ev.detail.value !== this._opened) {
 | 
			
		||||
      this._opened = ev.detail.value;
 | 
			
		||||
      fireEvent(this, "opened-changed", { value: this._opened });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    // Clear the input field to prevent showing the old value next time
 | 
			
		||||
    this.comboBox.setTextFieldValue("");
 | 
			
		||||
    const newValue = ev.detail.value?.trim();
 | 
			
		||||
    const value = (ev.currentTarget as any).value as string;
 | 
			
		||||
    const newValue = value?.trim();
 | 
			
		||||
 | 
			
		||||
    if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (newValue !== this._value) {
 | 
			
		||||
      this._setValue(newValue);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
    fireEvent(this, "value-changed", { value: newValue });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
 | 
			
		||||
    Fuse.createIndex(["search_labels"], states)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  private _filterChanged(ev: CustomEvent): void {
 | 
			
		||||
    if (!this._opened) return;
 | 
			
		||||
  private _filterChanged = (ev: Event) => {
 | 
			
		||||
    const textfield = ev.target as HaTextField;
 | 
			
		||||
    const searchString = textfield.value.trim();
 | 
			
		||||
 | 
			
		||||
    const target = ev.target as HaComboBox;
 | 
			
		||||
    const searchString = ev.detail.value.trim() as string;
 | 
			
		||||
    if (!searchString) {
 | 
			
		||||
      this._items = this._allItems;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const index = this._fuseIndex(this._items);
 | 
			
		||||
    const fuse = new HaFuse(this._items, { shouldSort: false }, index);
 | 
			
		||||
    const index = this._fuseIndex(this._allItems);
 | 
			
		||||
    const fuse = new HaFuse(
 | 
			
		||||
      this._allItems,
 | 
			
		||||
      {
 | 
			
		||||
        shouldSort: false,
 | 
			
		||||
        minMatchCharLength: Math.min(searchString.length, 2),
 | 
			
		||||
      },
 | 
			
		||||
      index
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const results = fuse.multiTermsSearch(searchString);
 | 
			
		||||
    let filteredItems = this._items as PickerComboBoxItem[];
 | 
			
		||||
    let filteredItems = this._allItems as PickerComboBoxItem[];
 | 
			
		||||
    if (results) {
 | 
			
		||||
      const items = results.map((result) => result.item);
 | 
			
		||||
      if (items.length === 0) {
 | 
			
		||||
        items.push(
 | 
			
		||||
          this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize)
 | 
			
		||||
          this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      const additionalItems = this._getAdditionalItems(searchString);
 | 
			
		||||
@@ -260,17 +287,279 @@ export class HaPickerComboBox extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.searchFn) {
 | 
			
		||||
      filteredItems = this.searchFn(searchString, filteredItems, this._items);
 | 
			
		||||
      filteredItems = this.searchFn(
 | 
			
		||||
        searchString,
 | 
			
		||||
        filteredItems,
 | 
			
		||||
        this._allItems
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    target.filteredItems = filteredItems;
 | 
			
		||||
    this._items = filteredItems as PickerComboBoxItemWithLabel[];
 | 
			
		||||
    this._selectedItemIndex = -1;
 | 
			
		||||
    if (this._virtualizerElement) {
 | 
			
		||||
      this._virtualizerElement.scrollTo(0, 0);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _registerKeyboardShortcuts() {
 | 
			
		||||
    this._removeKeyboardShortcuts = tinykeys(this, {
 | 
			
		||||
      ArrowUp: this._selectPreviousItem,
 | 
			
		||||
      ArrowDown: this._selectNextItem,
 | 
			
		||||
      Home: this._selectFirstItem,
 | 
			
		||||
      End: this._selectLastItem,
 | 
			
		||||
      Enter: this._pickSelectedItem,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _setValue(value: string | undefined) {
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      fireEvent(this, "value-changed", { value });
 | 
			
		||||
    }, 0);
 | 
			
		||||
  private _focusList() {
 | 
			
		||||
    if (this._selectedItemIndex === -1) {
 | 
			
		||||
      this._selectNextItem();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _selectNextItem = (ev?: KeyboardEvent) => {
 | 
			
		||||
    ev?.stopPropagation();
 | 
			
		||||
    ev?.preventDefault();
 | 
			
		||||
    if (!this._virtualizerElement) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this._searchFieldElement?.focus();
 | 
			
		||||
 | 
			
		||||
    const items = this._virtualizerElement.items as PickerComboBoxItem[];
 | 
			
		||||
 | 
			
		||||
    const maxItems = items.length - 1;
 | 
			
		||||
 | 
			
		||||
    if (maxItems === -1) {
 | 
			
		||||
      this._resetSelectedItem();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextIndex =
 | 
			
		||||
      maxItems === this._selectedItemIndex
 | 
			
		||||
        ? this._selectedItemIndex
 | 
			
		||||
        : this._selectedItemIndex + 1;
 | 
			
		||||
 | 
			
		||||
    if (!items[nextIndex]) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
 | 
			
		||||
      // Skip titles, padding and empty search
 | 
			
		||||
      if (nextIndex === maxItems) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this._selectedItemIndex = nextIndex + 1;
 | 
			
		||||
    } else {
 | 
			
		||||
      this._selectedItemIndex = nextIndex;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this._scrollToSelectedItem();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _selectPreviousItem = (ev: KeyboardEvent) => {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    ev.preventDefault();
 | 
			
		||||
    if (!this._virtualizerElement) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this._selectedItemIndex > 0) {
 | 
			
		||||
      const nextIndex = this._selectedItemIndex - 1;
 | 
			
		||||
 | 
			
		||||
      const items = this._virtualizerElement.items as PickerComboBoxItem[];
 | 
			
		||||
 | 
			
		||||
      if (!items[nextIndex]) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
 | 
			
		||||
        // Skip titles, padding and empty search
 | 
			
		||||
        if (nextIndex === 0) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        this._selectedItemIndex = nextIndex - 1;
 | 
			
		||||
      } else {
 | 
			
		||||
        this._selectedItemIndex = nextIndex;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this._scrollToSelectedItem();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _selectFirstItem = (ev: KeyboardEvent) => {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextIndex = 0;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
 | 
			
		||||
      NO_MATCHING_ITEMS_FOUND_ID
 | 
			
		||||
    ) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (typeof this._virtualizerElement.items[nextIndex] === "string") {
 | 
			
		||||
      this._selectedItemIndex = nextIndex + 1;
 | 
			
		||||
    } else {
 | 
			
		||||
      this._selectedItemIndex = nextIndex;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this._scrollToSelectedItem();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _selectLastItem = (ev: KeyboardEvent) => {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextIndex = this._virtualizerElement.items.length - 1;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
 | 
			
		||||
      NO_MATCHING_ITEMS_FOUND_ID
 | 
			
		||||
    ) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (typeof this._virtualizerElement.items[nextIndex] === "string") {
 | 
			
		||||
      this._selectedItemIndex = nextIndex - 1;
 | 
			
		||||
    } else {
 | 
			
		||||
      this._selectedItemIndex = nextIndex;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this._scrollToSelectedItem();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _scrollToSelectedItem = () => {
 | 
			
		||||
    this._virtualizerElement
 | 
			
		||||
      ?.querySelector(".selected")
 | 
			
		||||
      ?.classList.remove("selected");
 | 
			
		||||
 | 
			
		||||
    this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
 | 
			
		||||
 | 
			
		||||
    requestAnimationFrame(() => {
 | 
			
		||||
      this._virtualizerElement
 | 
			
		||||
        ?.querySelector(`#list-item-${this._selectedItemIndex}`)
 | 
			
		||||
        ?.classList.add("selected");
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _pickSelectedItem = (ev: KeyboardEvent) => {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      this._virtualizerElement?.items.length === 1 &&
 | 
			
		||||
      firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
 | 
			
		||||
    ) {
 | 
			
		||||
      fireEvent(this, "value-changed", {
 | 
			
		||||
        value: firstItem.id,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this._selectedItemIndex === -1) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // if filter button is focused
 | 
			
		||||
    ev.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const item = this._virtualizerElement?.items[
 | 
			
		||||
      this._selectedItemIndex
 | 
			
		||||
    ] as PickerComboBoxItem;
 | 
			
		||||
    if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
 | 
			
		||||
      fireEvent(this, "value-changed", { value: item.id });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _resetSelectedItem() {
 | 
			
		||||
    this._virtualizerElement
 | 
			
		||||
      ?.querySelector(".selected")
 | 
			
		||||
      ?.classList.remove("selected");
 | 
			
		||||
    this._selectedItemIndex = -1;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = [
 | 
			
		||||
    haStyleScrollbar,
 | 
			
		||||
    css`
 | 
			
		||||
      :host {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        padding-top: var(--ha-space-3);
 | 
			
		||||
        flex: 1;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      ha-textfield {
 | 
			
		||||
        padding: 0 var(--ha-space-3);
 | 
			
		||||
        margin-bottom: var(--ha-space-3);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :host([mode="dialog"]) ha-textfield {
 | 
			
		||||
        padding: 0 var(--ha-space-4);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      ha-combo-box-item {
 | 
			
		||||
        width: 100%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      ha-combo-box-item.selected {
 | 
			
		||||
        background-color: var(--ha-color-fill-neutral-quiet-hover);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      @media (prefers-color-scheme: dark) {
 | 
			
		||||
        ha-combo-box-item.selected {
 | 
			
		||||
          background-color: var(--ha-color-fill-neutral-normal-hover);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      lit-virtualizer {
 | 
			
		||||
        flex: 1;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      lit-virtualizer:focus-visible {
 | 
			
		||||
        outline: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      lit-virtualizer.scrolled {
 | 
			
		||||
        border-top: 1px solid var(--ha-color-border-neutral-quiet);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .bottom-padding {
 | 
			
		||||
        height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
 | 
			
		||||
        width: 100%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .empty {
 | 
			
		||||
        text-align: center;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .combo-box-row {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        box-sizing: border-box;
 | 
			
		||||
        min-height: 36px;
 | 
			
		||||
      }
 | 
			
		||||
      .combo-box-row.current-value {
 | 
			
		||||
        background-color: var(--ha-color-fill-primary-quiet-resting);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .combo-box-row.selected {
 | 
			
		||||
        background-color: var(--ha-color-fill-neutral-quiet-hover);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      @media (prefers-color-scheme: dark) {
 | 
			
		||||
        .combo-box-row.selected {
 | 
			
		||||
          background-color: var(--ha-color-fill-neutral-normal-hover);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    `,
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
 
 | 
			
		||||
@@ -137,7 +137,7 @@ export class HaSelect extends SelectBase {
 | 
			
		||||
        height: var(--ha-select-height, 56px);
 | 
			
		||||
      }
 | 
			
		||||
      .mdc-select--filled .mdc-floating-label {
 | 
			
		||||
        inset-inline-start: 12px;
 | 
			
		||||
        inset-inline-start: var(--ha-space-4);
 | 
			
		||||
        inset-inline-end: initial;
 | 
			
		||||
        direction: var(--direction);
 | 
			
		||||
      }
 | 
			
		||||
@@ -147,7 +147,7 @@ export class HaSelect extends SelectBase {
 | 
			
		||||
        direction: var(--direction);
 | 
			
		||||
      }
 | 
			
		||||
      .mdc-select .mdc-select__anchor {
 | 
			
		||||
        padding-inline-start: 12px;
 | 
			
		||||
        padding-inline-start: var(--ha-space-4);
 | 
			
		||||
        padding-inline-end: 0px;
 | 
			
		||||
        direction: var(--direction);
 | 
			
		||||
      }
 | 
			
		||||
@@ -158,7 +158,10 @@ export class HaSelect extends SelectBase {
 | 
			
		||||
        padding-inline-end: var(--select-selected-text-padding-end, 0px);
 | 
			
		||||
      }
 | 
			
		||||
      :host([clearable]) .mdc-select__selected-text-container {
 | 
			
		||||
        padding-inline-end: var(--select-selected-text-padding-end, 12px);
 | 
			
		||||
        padding-inline-end: var(
 | 
			
		||||
          --select-selected-text-padding-end,
 | 
			
		||||
          var(--ha-space-4)
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      ha-icon-button {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,122 +0,0 @@
 | 
			
		||||
import { css, html, LitElement } from "lit";
 | 
			
		||||
import { customElement, property, state } from "lit/decorators";
 | 
			
		||||
import { fireEvent } from "../../common/dom/fire_event";
 | 
			
		||||
import type { BackgroundSelector } from "../../data/selector";
 | 
			
		||||
import type { HomeAssistant } from "../../types";
 | 
			
		||||
import "../ha-picture-upload";
 | 
			
		||||
import "../ha-alert";
 | 
			
		||||
import type { HaPictureUpload } from "../ha-picture-upload";
 | 
			
		||||
import { URL_PREFIX } from "../../data/image_upload";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-selector-background")
 | 
			
		||||
export class HaBackgroundSelector extends LitElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
 | 
			
		||||
  @property() public value?: any;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false }) public selector!: BackgroundSelector;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public disabled = false;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public required = true;
 | 
			
		||||
 | 
			
		||||
  @state() private yamlBackground = false;
 | 
			
		||||
 | 
			
		||||
  protected updated(changedProps) {
 | 
			
		||||
    super.updated(changedProps);
 | 
			
		||||
 | 
			
		||||
    if (changedProps.has("value")) {
 | 
			
		||||
      this.yamlBackground = !!this.value && !this.value.startsWith(URL_PREFIX);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    return html`
 | 
			
		||||
      <div>
 | 
			
		||||
        ${this.yamlBackground
 | 
			
		||||
          ? html`
 | 
			
		||||
              <div class="value">
 | 
			
		||||
                <img
 | 
			
		||||
                  src=${this.value}
 | 
			
		||||
                  alt=${this.hass.localize(
 | 
			
		||||
                    "ui.components.picture-upload.current_image_alt"
 | 
			
		||||
                  )}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
              <ha-alert alert-type="info">
 | 
			
		||||
                ${this.hass.localize(
 | 
			
		||||
                  `ui.components.selectors.background.yaml_info`
 | 
			
		||||
                )}
 | 
			
		||||
                <ha-button slot="action" @click=${this._clearValue}>
 | 
			
		||||
                  ${this.hass.localize(
 | 
			
		||||
                    `ui.components.picture-upload.clear_picture`
 | 
			
		||||
                  )}
 | 
			
		||||
                </ha-button>
 | 
			
		||||
              </ha-alert>
 | 
			
		||||
            `
 | 
			
		||||
          : html`
 | 
			
		||||
              <ha-picture-upload
 | 
			
		||||
                .hass=${this.hass}
 | 
			
		||||
                .value=${this.value?.startsWith(URL_PREFIX) ? this.value : null}
 | 
			
		||||
                .original=${!!this.selector.background?.original}
 | 
			
		||||
                .cropOptions=${this.selector.background?.crop}
 | 
			
		||||
                select-media
 | 
			
		||||
                @change=${this._pictureChanged}
 | 
			
		||||
              ></ha-picture-upload>
 | 
			
		||||
            `}
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _pictureChanged(ev) {
 | 
			
		||||
    const value = (ev.target as HaPictureUpload).value;
 | 
			
		||||
 | 
			
		||||
    fireEvent(this, "value-changed", { value: value ?? undefined });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _clearValue() {
 | 
			
		||||
    fireEvent(this, "value-changed", { value: undefined });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    :host {
 | 
			
		||||
      display: block;
 | 
			
		||||
      position: relative;
 | 
			
		||||
    }
 | 
			
		||||
    ha-picture-upload {
 | 
			
		||||
      background-color: var(--primary-background-color);
 | 
			
		||||
      border-radius: var(--file-upload-image-border-radius);
 | 
			
		||||
    }
 | 
			
		||||
    div {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
    }
 | 
			
		||||
    ha-button {
 | 
			
		||||
      white-space: nowrap;
 | 
			
		||||
      --mdc-theme-primary: var(--primary-color);
 | 
			
		||||
    }
 | 
			
		||||
    .value {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-direction: column;
 | 
			
		||||
      align-items: center;
 | 
			
		||||
    }
 | 
			
		||||
    img {
 | 
			
		||||
      max-width: 100%;
 | 
			
		||||
      max-height: 200px;
 | 
			
		||||
      margin-bottom: 4px;
 | 
			
		||||
      border-radius: var(--file-upload-image-border-radius);
 | 
			
		||||
      transition: opacity 0.3s;
 | 
			
		||||
      opacity: var(--picture-opacity, 1);
 | 
			
		||||
    }
 | 
			
		||||
    img:hover {
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  interface HTMLElementTagNameMap {
 | 
			
		||||
    "ha-selector-background": HaBackgroundSelector;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -52,9 +52,10 @@ export class HaObjectSelector extends LitElement {
 | 
			
		||||
    const translationKey = this.selector.object?.translation_key;
 | 
			
		||||
 | 
			
		||||
    if (this.localizeValue && translationKey) {
 | 
			
		||||
      const label = this.localizeValue(
 | 
			
		||||
        `${translationKey}.fields.${schema.name}`
 | 
			
		||||
      );
 | 
			
		||||
      const label =
 | 
			
		||||
        this.localizeValue(`${translationKey}.fields.${schema.name}.name`) ||
 | 
			
		||||
        // Fallback for backward compatibility
 | 
			
		||||
        this.localizeValue(`${translationKey}.fields.${schema.name}`);
 | 
			
		||||
      if (label) {
 | 
			
		||||
        return label;
 | 
			
		||||
      }
 | 
			
		||||
@@ -62,6 +63,20 @@ export class HaObjectSelector extends LitElement {
 | 
			
		||||
    return this.selector.object?.fields?.[schema.name]?.label || schema.name;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _computeHelper = (schema: HaFormSchema): string => {
 | 
			
		||||
    const translationKey = this.selector.object?.translation_key;
 | 
			
		||||
 | 
			
		||||
    if (this.localizeValue && translationKey) {
 | 
			
		||||
      const helper = this.localizeValue(
 | 
			
		||||
        `${translationKey}.fields.${schema.name}.description`
 | 
			
		||||
      );
 | 
			
		||||
      if (helper) {
 | 
			
		||||
        return helper;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return this.selector.object?.fields?.[schema.name]?.description || "";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _renderItem(item: any, index: number) {
 | 
			
		||||
    const labelField =
 | 
			
		||||
      this.selector.object!.label_field ||
 | 
			
		||||
@@ -214,6 +229,7 @@ export class HaObjectSelector extends LitElement {
 | 
			
		||||
      schema: this._schema(this.selector),
 | 
			
		||||
      data: {},
 | 
			
		||||
      computeLabel: this._computeLabel,
 | 
			
		||||
      computeHelper: this._computeHelper,
 | 
			
		||||
      submitText: this.hass.localize("ui.common.add"),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) {
 | 
			
		||||
        .helper=${this.helper}
 | 
			
		||||
        .disabled=${this.disabled}
 | 
			
		||||
        .required=${this.required}
 | 
			
		||||
        .allowName=${this.selector.ui_state_content?.allow_name}
 | 
			
		||||
        .allowName=${this.selector.ui_state_content?.allow_name || false}
 | 
			
		||||
      ></ha-entity-state-content-picker>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,6 @@ const LOAD_ELEMENTS = {
 | 
			
		||||
  file: () => import("./ha-selector-file"),
 | 
			
		||||
  floor: () => import("./ha-selector-floor"),
 | 
			
		||||
  label: () => import("./ha-selector-label"),
 | 
			
		||||
  background: () => import("./ha-selector-background"),
 | 
			
		||||
  language: () => import("./ha-selector-language"),
 | 
			
		||||
  navigation: () => import("./ha-selector-navigation"),
 | 
			
		||||
  number: () => import("./ha-selector-number"),
 | 
			
		||||
 
 | 
			
		||||
@@ -53,7 +53,7 @@ class HaServicePicker extends LitElement {
 | 
			
		||||
    item,
 | 
			
		||||
    { index }
 | 
			
		||||
  ) => html`
 | 
			
		||||
    <ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
 | 
			
		||||
    <ha-combo-box-item type="button" .borderTop=${index !== 0}>
 | 
			
		||||
      <ha-service-icon
 | 
			
		||||
        slot="start"
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
@@ -76,34 +76,42 @@ class HaServicePicker extends LitElement {
 | 
			
		||||
    </ha-combo-box-item>
 | 
			
		||||
  `;
 | 
			
		||||
 | 
			
		||||
  private _valueRenderer: PickerValueRenderer = (value) => {
 | 
			
		||||
    const serviceId = value;
 | 
			
		||||
    const [domain, service] = serviceId.split(".");
 | 
			
		||||
  private _valueRenderer = memoizeOne(
 | 
			
		||||
    (
 | 
			
		||||
      localize: LocalizeFunc,
 | 
			
		||||
      services: HomeAssistant["services"]
 | 
			
		||||
    ): PickerValueRenderer =>
 | 
			
		||||
      (value) => {
 | 
			
		||||
        const serviceId = value;
 | 
			
		||||
        const [domain, service] = serviceId.split(".");
 | 
			
		||||
 | 
			
		||||
    if (!this.hass.services[domain]?.[service]) {
 | 
			
		||||
      return html`
 | 
			
		||||
        <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
 | 
			
		||||
        <span slot="headline">${value}</span>
 | 
			
		||||
      `;
 | 
			
		||||
    }
 | 
			
		||||
        if (!services[domain]?.[service]) {
 | 
			
		||||
          return html`
 | 
			
		||||
            <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
 | 
			
		||||
            <span slot="headline">${value}</span>
 | 
			
		||||
          `;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    const serviceName =
 | 
			
		||||
      this.hass.localize(`component.${domain}.services.${service}.name`) ||
 | 
			
		||||
      this.hass.services[domain][service].name ||
 | 
			
		||||
      service;
 | 
			
		||||
        const serviceName =
 | 
			
		||||
          localize(`component.${domain}.services.${service}.name`) ||
 | 
			
		||||
          services[domain][service].name ||
 | 
			
		||||
          service;
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-service-icon
 | 
			
		||||
        slot="start"
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .service=${serviceId}
 | 
			
		||||
      ></ha-service-icon>
 | 
			
		||||
      <span slot="headline">${serviceName}</span>
 | 
			
		||||
      ${this.showServiceId
 | 
			
		||||
        ? html`<span slot="supporting-text" class="code">${serviceId}</span>`
 | 
			
		||||
        : nothing}
 | 
			
		||||
    `;
 | 
			
		||||
  };
 | 
			
		||||
        return html`
 | 
			
		||||
          <ha-service-icon
 | 
			
		||||
            slot="start"
 | 
			
		||||
            .hass=${this.hass}
 | 
			
		||||
            .service=${serviceId}
 | 
			
		||||
          ></ha-service-icon>
 | 
			
		||||
          <span slot="headline">${serviceName}</span>
 | 
			
		||||
          ${this.showServiceId
 | 
			
		||||
            ? html`<span slot="supporting-text" class="code"
 | 
			
		||||
                >${serviceId}</span
 | 
			
		||||
              >`
 | 
			
		||||
            : nothing}
 | 
			
		||||
        `;
 | 
			
		||||
      }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  protected render(): TemplateResult {
 | 
			
		||||
    const placeholder =
 | 
			
		||||
@@ -123,7 +131,10 @@ class HaServicePicker extends LitElement {
 | 
			
		||||
        .value=${this.value}
 | 
			
		||||
        .getItems=${this._getItems}
 | 
			
		||||
        .rowRenderer=${this._rowRenderer}
 | 
			
		||||
        .valueRenderer=${this._valueRenderer}
 | 
			
		||||
        .valueRenderer=${this._valueRenderer(
 | 
			
		||||
          this.hass.localize,
 | 
			
		||||
          this.hass.services
 | 
			
		||||
        )}
 | 
			
		||||
        @value-changed=${this._valueChanged}
 | 
			
		||||
      >
 | 
			
		||||
      </ha-generic-picker>
 | 
			
		||||
@@ -162,7 +173,9 @@ class HaServicePicker extends LitElement {
 | 
			
		||||
            const description =
 | 
			
		||||
              this.hass.localize(
 | 
			
		||||
                `component.${domain}.services.${service}.description`
 | 
			
		||||
              ) || services[domain][service].description;
 | 
			
		||||
              ) ||
 | 
			
		||||
              services[domain][service].description ||
 | 
			
		||||
              "";
 | 
			
		||||
 | 
			
		||||
            items.push({
 | 
			
		||||
              id: serviceId,
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,7 @@ import memoizeOne from "memoize-one";
 | 
			
		||||
import { fireEvent } from "../common/dom/fire_event";
 | 
			
		||||
import { toggleAttribute } from "../common/dom/toggle_attribute";
 | 
			
		||||
import { stringCompare } from "../common/string/compare";
 | 
			
		||||
import { computeRTL } from "../common/util/compute_rtl";
 | 
			
		||||
import { throttle } from "../common/util/throttle";
 | 
			
		||||
import { subscribeFrontendUserData } from "../data/frontend";
 | 
			
		||||
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
 | 
			
		||||
@@ -536,11 +537,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _renderUserItem(selectedPanel: string) {
 | 
			
		||||
    const isRTL = computeRTL(this.hass);
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-md-list-item
 | 
			
		||||
        href="/profile"
 | 
			
		||||
        type="link"
 | 
			
		||||
        class="user ${selectedPanel === "profile" ? " selected" : ""}"
 | 
			
		||||
        class=${classMap({
 | 
			
		||||
          user: true,
 | 
			
		||||
          selected: selectedPanel === "profile",
 | 
			
		||||
          rtl: isRTL,
 | 
			
		||||
        })}
 | 
			
		||||
        @mouseenter=${this._itemMouseEnter}
 | 
			
		||||
        @mouseleave=${this._itemMouseLeave}
 | 
			
		||||
      >
 | 
			
		||||
@@ -666,7 +673,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
    tooltip.style.display = "block";
 | 
			
		||||
    tooltip.style.position = "fixed";
 | 
			
		||||
    tooltip.style.top = `${top}px`;
 | 
			
		||||
    tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`;
 | 
			
		||||
    tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, var(--ha-space-0)))`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _hideTooltip() {
 | 
			
		||||
@@ -705,13 +712,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
          background-color: var(--sidebar-background-color);
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px));
 | 
			
		||||
          padding-bottom: calc(
 | 
			
		||||
            14px + var(--safe-area-inset-bottom, var(--ha-space-0))
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
        .menu {
 | 
			
		||||
          height: calc(var(--header-height) + var(--safe-area-inset-top, 0px));
 | 
			
		||||
          height: calc(
 | 
			
		||||
            var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0))
 | 
			
		||||
          );
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          display: flex;
 | 
			
		||||
          padding: 0 4px;
 | 
			
		||||
          padding: 0 var(--ha-space-1);
 | 
			
		||||
          border-bottom: 1px solid transparent;
 | 
			
		||||
          white-space: nowrap;
 | 
			
		||||
          font-weight: var(--ha-font-weight-normal);
 | 
			
		||||
@@ -726,13 +737,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
          );
 | 
			
		||||
          font-size: var(--ha-font-size-xl);
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          padding-left: calc(4px + var(--safe-area-inset-left, 0px));
 | 
			
		||||
          padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px));
 | 
			
		||||
          padding-left: calc(
 | 
			
		||||
            var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
 | 
			
		||||
          );
 | 
			
		||||
          padding-inline-start: calc(
 | 
			
		||||
            var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
 | 
			
		||||
          );
 | 
			
		||||
          padding-inline-end: initial;
 | 
			
		||||
          padding-top: var(--safe-area-inset-top, 0px);
 | 
			
		||||
          padding-top: var(--safe-area-inset-top, var(--ha-space-0));
 | 
			
		||||
        }
 | 
			
		||||
        :host([expanded]) .menu {
 | 
			
		||||
          width: calc(256px + var(--safe-area-inset-left, 0px));
 | 
			
		||||
          width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0)));
 | 
			
		||||
        }
 | 
			
		||||
        :host([narrow][expanded]) .menu {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
@@ -748,8 +763,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
          display: none;
 | 
			
		||||
        }
 | 
			
		||||
        :host([narrow]) .title {
 | 
			
		||||
          margin: 0;
 | 
			
		||||
          padding: 0 16px;
 | 
			
		||||
          margin: var(--ha-space-0);
 | 
			
		||||
          padding: var(--ha-space-0) var(--ha-space-4);
 | 
			
		||||
        }
 | 
			
		||||
        :host([expanded]) .title {
 | 
			
		||||
          display: initial;
 | 
			
		||||
@@ -761,13 +776,16 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
        ha-fade-in,
 | 
			
		||||
        ha-md-list {
 | 
			
		||||
          height: calc(
 | 
			
		||||
            100% - var(--header-height) - var(--safe-area-inset-top, 0px) -
 | 
			
		||||
            100% - var(--header-height) - var(
 | 
			
		||||
                --safe-area-inset-top,
 | 
			
		||||
                var(--ha-space-0)
 | 
			
		||||
              ) -
 | 
			
		||||
              132px
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-fade-in {
 | 
			
		||||
          padding: 4px 0;
 | 
			
		||||
          padding: var(--ha-space-1) var(--ha-space-0);
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          display: flex;
 | 
			
		||||
          justify-content: center;
 | 
			
		||||
@@ -777,29 +795,29 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
        ha-md-list {
 | 
			
		||||
          overflow-x: hidden;
 | 
			
		||||
          background: none;
 | 
			
		||||
          margin-left: var(--safe-area-inset-left, 0px);
 | 
			
		||||
          margin-left: var(--safe-area-inset-left, var(--ha-space-0));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-md-list-item {
 | 
			
		||||
          flex-shrink: 0;
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          margin: 4px;
 | 
			
		||||
          margin: var(--ha-space-1);
 | 
			
		||||
          border-radius: var(--ha-border-radius-sm);
 | 
			
		||||
          --md-list-item-one-line-container-height: 40px;
 | 
			
		||||
          --md-list-item-one-line-container-height: var(--ha-space-10);
 | 
			
		||||
          --md-list-item-top-space: 0;
 | 
			
		||||
          --md-list-item-bottom-space: 0;
 | 
			
		||||
          width: 48px;
 | 
			
		||||
          width: var(--ha-space-12);
 | 
			
		||||
          position: relative;
 | 
			
		||||
          --md-list-item-label-text-color: var(--sidebar-text-color);
 | 
			
		||||
          --md-list-item-leading-space: 12px;
 | 
			
		||||
          --md-list-item-trailing-space: 12px;
 | 
			
		||||
          --md-list-item-leading-icon-size: 24px;
 | 
			
		||||
          --md-list-item-leading-space: var(--ha-space-3);
 | 
			
		||||
          --md-list-item-trailing-space: var(--ha-space-3);
 | 
			
		||||
          --md-list-item-leading-icon-size: var(--ha-space-6);
 | 
			
		||||
        }
 | 
			
		||||
        :host([expanded]) ha-md-list-item {
 | 
			
		||||
          width: 248px;
 | 
			
		||||
        }
 | 
			
		||||
        :host([narrow][expanded]) ha-md-list-item {
 | 
			
		||||
          width: calc(240px - var(--safe-area-inset-left, 0px));
 | 
			
		||||
          width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0)));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-md-list-item.selected {
 | 
			
		||||
@@ -823,7 +841,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
 | 
			
		||||
        ha-icon[slot="start"],
 | 
			
		||||
        ha-svg-icon[slot="start"] {
 | 
			
		||||
          width: 24px;
 | 
			
		||||
          width: var(--ha-space-6);
 | 
			
		||||
          flex-shrink: 0;
 | 
			
		||||
          color: var(--sidebar-icon-color);
 | 
			
		||||
        }
 | 
			
		||||
@@ -856,7 +874,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          justify-content: center;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          min-width: 8px;
 | 
			
		||||
          min-width: var(--ha-space-2);
 | 
			
		||||
          border-radius: var(--ha-border-radius-xl);
 | 
			
		||||
          font-weight: var(--ha-font-weight-normal);
 | 
			
		||||
          line-height: normal;
 | 
			
		||||
@@ -867,22 +885,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
 | 
			
		||||
        ha-svg-icon + .badge {
 | 
			
		||||
          position: absolute;
 | 
			
		||||
          top: 4px;
 | 
			
		||||
          top: var(--ha-space-1);
 | 
			
		||||
          left: 26px;
 | 
			
		||||
          border-radius: var(--ha-border-radius-md);
 | 
			
		||||
          font-size: 0.65em;
 | 
			
		||||
          line-height: var(--ha-line-height-expanded);
 | 
			
		||||
          padding: 0 4px;
 | 
			
		||||
          padding: var(--ha-space-0) var(--ha-space-1);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-md-list-item.user {
 | 
			
		||||
          --md-list-item-leading-icon-size: 40px;
 | 
			
		||||
          --md-list-item-leading-space: 4px;
 | 
			
		||||
          --md-list-item-leading-icon-size: var(--ha-space-10);
 | 
			
		||||
          --md-list-item-leading-space: var(--ha-space-1);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-md-list-item.user.rtl {
 | 
			
		||||
          --md-list-item-leading-space: var(--ha-space-3);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-user-badge {
 | 
			
		||||
          flex-shrink: 0;
 | 
			
		||||
          margin-right: -8px;
 | 
			
		||||
          margin-right: calc(var(--ha-space-2) * -1);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .spacer {
 | 
			
		||||
@@ -894,7 +916,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
          color: var(--sidebar-text-color);
 | 
			
		||||
          font-size: var(--ha-font-size-m);
 | 
			
		||||
          font-weight: var(--ha-font-weight-medium);
 | 
			
		||||
          padding: 16px;
 | 
			
		||||
          padding: var(--ha-space-4);
 | 
			
		||||
          white-space: nowrap;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -906,7 +928,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
 | 
			
		||||
          white-space: nowrap;
 | 
			
		||||
          color: var(--sidebar-background-color);
 | 
			
		||||
          background-color: var(--sidebar-text-color);
 | 
			
		||||
          padding: 4px;
 | 
			
		||||
          padding: var(--ha-space-1);
 | 
			
		||||
          font-weight: var(--ha-font-weight-medium);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,26 +2,13 @@ import { TopAppBarFixedBase } from "@material/mwc-top-app-bar-fixed/mwc-top-app-
 | 
			
		||||
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
 | 
			
		||||
import { css } from "lit";
 | 
			
		||||
import { customElement, property } from "lit/decorators";
 | 
			
		||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
 | 
			
		||||
import { haStyleViewTransitions } from "../resources/styles";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-top-app-bar-fixed")
 | 
			
		||||
export class HaTopAppBarFixed extends ViewTransitionMixin(TopAppBarFixedBase) {
 | 
			
		||||
export class HaTopAppBarFixed extends TopAppBarFixedBase {
 | 
			
		||||
  @property({ type: Boolean, reflect: true }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean, reflect: true, attribute: "content-loading" })
 | 
			
		||||
  public contentLoading = true;
 | 
			
		||||
 | 
			
		||||
  protected override onLoadTransition(): void {
 | 
			
		||||
    // Use reflected property since we can't add class to base component's rendered elements
 | 
			
		||||
    this.startViewTransition(() => {
 | 
			
		||||
      this.contentLoading = false;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static override styles = [
 | 
			
		||||
    styles,
 | 
			
		||||
    haStyleViewTransitions,
 | 
			
		||||
    css`
 | 
			
		||||
      header {
 | 
			
		||||
        padding-top: var(--safe-area-inset-top);
 | 
			
		||||
@@ -36,10 +23,6 @@ export class HaTopAppBarFixed extends ViewTransitionMixin(TopAppBarFixedBase) {
 | 
			
		||||
        );
 | 
			
		||||
        padding-bottom: var(--safe-area-inset-bottom);
 | 
			
		||||
        padding-right: var(--safe-area-inset-right);
 | 
			
		||||
        view-transition-name: layout-fade-in;
 | 
			
		||||
      }
 | 
			
		||||
      :host([content-loading]) .mdc-top-app-bar--fixed-adjust {
 | 
			
		||||
        opacity: 0;
 | 
			
		||||
      }
 | 
			
		||||
      :host([narrow]) .mdc-top-app-bar--fixed-adjust {
 | 
			
		||||
        padding-left: var(--safe-area-inset-left);
 | 
			
		||||
 
 | 
			
		||||
@@ -10,15 +10,14 @@ import { html, css, nothing } from "lit";
 | 
			
		||||
import { property, query, customElement } from "lit/decorators";
 | 
			
		||||
import { classMap } from "lit/directives/class-map";
 | 
			
		||||
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
 | 
			
		||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
 | 
			
		||||
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
 | 
			
		||||
import { haStyleScrollbar } from "../resources/styles";
 | 
			
		||||
 | 
			
		||||
export const passiveEventOptionsIfSupported = supportsPassiveEventListener
 | 
			
		||||
  ? { passive: true }
 | 
			
		||||
  : undefined;
 | 
			
		||||
 | 
			
		||||
@customElement("ha-two-pane-top-app-bar-fixed")
 | 
			
		||||
export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
 | 
			
		||||
export class TopAppBarBaseBase extends BaseElement {
 | 
			
		||||
  protected override mdcFoundation!: MDCFixedTopAppBarFoundation;
 | 
			
		||||
 | 
			
		||||
  protected override mdcFoundationClass = MDCFixedTopAppBarFoundation;
 | 
			
		||||
@@ -145,12 +144,7 @@ export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
 | 
			
		||||
          : nothing}
 | 
			
		||||
        <div class="main">
 | 
			
		||||
          ${this.pane ? html`<div class="shadow-container"></div>` : nothing}
 | 
			
		||||
          <div
 | 
			
		||||
            class=${classMap({
 | 
			
		||||
              content: true,
 | 
			
		||||
              loading: !this._loaded,
 | 
			
		||||
            })}
 | 
			
		||||
          >
 | 
			
		||||
          <div class="content">
 | 
			
		||||
            <slot></slot>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -251,7 +245,6 @@ export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
 | 
			
		||||
  static override styles = [
 | 
			
		||||
    styles,
 | 
			
		||||
    haStyleScrollbar,
 | 
			
		||||
    haStyleViewTransitions,
 | 
			
		||||
    css`
 | 
			
		||||
      header {
 | 
			
		||||
        padding-top: var(--safe-area-inset-top);
 | 
			
		||||
@@ -348,10 +341,6 @@ export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
 | 
			
		||||
      .mdc-top-app-bar--pane .content {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        overflow: auto;
 | 
			
		||||
        view-transition-name: layout-fade-in;
 | 
			
		||||
      }
 | 
			
		||||
      .content.loading {
 | 
			
		||||
        opacity: 0;
 | 
			
		||||
      }
 | 
			
		||||
      .mdc-top-app-bar__title {
 | 
			
		||||
        font-size: var(--ha-font-size-xl);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,18 @@
 | 
			
		||||
import { css, html, LitElement, nothing } from "lit";
 | 
			
		||||
import { customElement, property, state } from "lit/decorators";
 | 
			
		||||
import "@home-assistant/webawesome/dist/components/dialog/dialog";
 | 
			
		||||
import { mdiClose } from "@mdi/js";
 | 
			
		||||
import "./ha-dialog-header";
 | 
			
		||||
import "./ha-icon-button";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
import { css, html, LitElement, nothing } from "lit";
 | 
			
		||||
import {
 | 
			
		||||
  customElement,
 | 
			
		||||
  eventOptions,
 | 
			
		||||
  property,
 | 
			
		||||
  query,
 | 
			
		||||
  state,
 | 
			
		||||
} from "lit/decorators";
 | 
			
		||||
import { fireEvent } from "../common/dom/fire_event";
 | 
			
		||||
import { haStyleScrollbar } from "../resources/styles";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
import "./ha-dialog-header";
 | 
			
		||||
import "./ha-icon-button";
 | 
			
		||||
 | 
			
		||||
export type DialogWidth = "small" | "medium" | "large" | "full";
 | 
			
		||||
 | 
			
		||||
@@ -90,6 +96,11 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
  @state()
 | 
			
		||||
  private _open = false;
 | 
			
		||||
 | 
			
		||||
  @query(".body") public bodyContainer!: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  private _bodyScrolled = false;
 | 
			
		||||
 | 
			
		||||
  protected updated(
 | 
			
		||||
    changedProperties: Map<string | number | symbol, unknown>
 | 
			
		||||
  ): void {
 | 
			
		||||
@@ -107,10 +118,14 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
        .lightDismiss=${!this.preventScrimClose}
 | 
			
		||||
        without-header
 | 
			
		||||
        @wa-show=${this._handleShow}
 | 
			
		||||
        @wa-after-show=${this._handleAfterShow}
 | 
			
		||||
        @wa-after-hide=${this._handleAfterHide}
 | 
			
		||||
      >
 | 
			
		||||
        <slot name="header">
 | 
			
		||||
          <ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}>
 | 
			
		||||
          <ha-dialog-header
 | 
			
		||||
            .subtitlePosition=${this.headerSubtitlePosition}
 | 
			
		||||
            .showBorder=${this._bodyScrolled}
 | 
			
		||||
          >
 | 
			
		||||
            <slot name="headerNavigationIcon" slot="navigationIcon">
 | 
			
		||||
              <ha-icon-button
 | 
			
		||||
                data-dialog="close"
 | 
			
		||||
@@ -129,7 +144,7 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
            <slot name="headerActionItems" slot="actionItems"></slot>
 | 
			
		||||
          </ha-dialog-header>
 | 
			
		||||
        </slot>
 | 
			
		||||
        <div class="body ha-scrollbar">
 | 
			
		||||
        <div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
 | 
			
		||||
          <slot></slot>
 | 
			
		||||
        </div>
 | 
			
		||||
        <slot name="footer" slot="footer"></slot>
 | 
			
		||||
@@ -146,6 +161,10 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
    (this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _handleAfterShow = () => {
 | 
			
		||||
    fireEvent(this, "after-show");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _handleAfterHide = () => {
 | 
			
		||||
    this._open = false;
 | 
			
		||||
    fireEvent(this, "closed");
 | 
			
		||||
@@ -156,6 +175,11 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
    this._open = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @eventOptions({ passive: true })
 | 
			
		||||
  private _handleBodyScroll(ev: Event) {
 | 
			
		||||
    this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = [
 | 
			
		||||
    haStyleScrollbar,
 | 
			
		||||
    css`
 | 
			
		||||
@@ -172,7 +196,7 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
        );
 | 
			
		||||
        --width: var(--ha-dialog-width-md, min(580px, var(--full-width)));
 | 
			
		||||
        --width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
 | 
			
		||||
        --spacing: var(--dialog-content-padding, var(--ha-space-6));
 | 
			
		||||
        --show-duration: var(--ha-dialog-show-duration, 200ms);
 | 
			
		||||
        --hide-duration: var(--ha-dialog-hide-duration, 200ms);
 | 
			
		||||
@@ -193,11 +217,11 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :host([width="small"]) wa-dialog {
 | 
			
		||||
        --width: var(--ha-dialog-width-sm, min(320px, var(--full-width)));
 | 
			
		||||
        --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :host([width="large"]) wa-dialog {
 | 
			
		||||
        --width: var(--ha-dialog-width-lg, min(720px, var(--full-width)));
 | 
			
		||||
        --width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :host([width="full"]) wa-dialog {
 | 
			
		||||
@@ -211,6 +235,7 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
          --ha-dialog-max-height,
 | 
			
		||||
          calc(100% - var(--ha-space-20))
 | 
			
		||||
        );
 | 
			
		||||
        min-height: var(--ha-dialog-min-height);
 | 
			
		||||
        position: var(--dialog-surface-position, relative);
 | 
			
		||||
        margin-top: var(--dialog-surface-margin-top, auto);
 | 
			
		||||
        display: flex;
 | 
			
		||||
@@ -284,6 +309,7 @@ export class HaWaDialog extends LitElement {
 | 
			
		||||
      }
 | 
			
		||||
      :host([flexcontent]) .body {
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
        flex: 1;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
      }
 | 
			
		||||
@@ -312,6 +338,7 @@ declare global {
 | 
			
		||||
 | 
			
		||||
  interface HASSDomEvents {
 | 
			
		||||
    opened: undefined;
 | 
			
		||||
    "after-show": undefined;
 | 
			
		||||
    closed: undefined;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ import { classMap } from "lit/directives/class-map";
 | 
			
		||||
import { styleMap } from "lit/directives/style-map";
 | 
			
		||||
import { until } from "lit/directives/until";
 | 
			
		||||
import { fireEvent } from "../../common/dom/fire_event";
 | 
			
		||||
import { slugify } from "../../common/string/slugify";
 | 
			
		||||
import { debounce } from "../../common/util/debounce";
 | 
			
		||||
import { isUnavailableState } from "../../data/entity";
 | 
			
		||||
import type {
 | 
			
		||||
@@ -693,10 +694,12 @@ export class HaMediaPlayerBrowse extends LitElement {
 | 
			
		||||
                `
 | 
			
		||||
              : ""}
 | 
			
		||||
          </div>
 | 
			
		||||
          <ha-tooltip .for="grid-${child.title}" distance="-4">
 | 
			
		||||
          <ha-tooltip .for="grid-${slugify(child.title)}" distance="-4">
 | 
			
		||||
            ${child.title}
 | 
			
		||||
          </ha-tooltip>
 | 
			
		||||
          <div .id="grid-${child.title}" class="title">${child.title}</div>
 | 
			
		||||
          <div .id="grid-${slugify(child.title)}" class="title">
 | 
			
		||||
            ${child.title}
 | 
			
		||||
          </div>
 | 
			
		||||
        </ha-card>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,15 @@
 | 
			
		||||
import { mdiClose } from "@mdi/js";
 | 
			
		||||
import { css, html, LitElement, nothing } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
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-dialog";
 | 
			
		||||
import type { HaMdDialog } from "../../ha-md-dialog";
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
@@ -21,14 +19,15 @@ class DialogTargetDetails extends LitElement implements HassDialog {
 | 
			
		||||
 | 
			
		||||
  @state() private _params?: TargetDetailsDialogParams;
 | 
			
		||||
 | 
			
		||||
  @query("ha-md-dialog") private _dialog?: HaMdDialog;
 | 
			
		||||
  @state() private _opened = false;
 | 
			
		||||
 | 
			
		||||
  public showDialog(params: TargetDetailsDialogParams): void {
 | 
			
		||||
    this._params = params;
 | 
			
		||||
    this._opened = true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public closeDialog() {
 | 
			
		||||
    this._dialog?.close();
 | 
			
		||||
    this._opened = false;
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -43,58 +42,31 @@ class DialogTargetDetails extends LitElement implements HassDialog {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-md-dialog open @closed=${this._dialogClosed}>
 | 
			
		||||
        <ha-dialog-header slot="headline">
 | 
			
		||||
          <ha-icon-button
 | 
			
		||||
            slot="navigationIcon"
 | 
			
		||||
            @click=${this.closeDialog}
 | 
			
		||||
            .label=${this.hass.localize("ui.common.close")}
 | 
			
		||||
            .path=${mdiClose}
 | 
			
		||||
          ></ha-icon-button>
 | 
			
		||||
          <span slot="title"
 | 
			
		||||
            >${this.hass.localize(
 | 
			
		||||
              "ui.components.target-picker.target_details"
 | 
			
		||||
            )}</span
 | 
			
		||||
          >
 | 
			
		||||
          <span slot="subtitle"
 | 
			
		||||
            >${this.hass.localize(
 | 
			
		||||
              `ui.components.target-picker.type.${this._params.type}`
 | 
			
		||||
            )}:
 | 
			
		||||
            ${this._params.title}</span
 | 
			
		||||
          >
 | 
			
		||||
        </ha-dialog-header>
 | 
			
		||||
        <div slot="content">
 | 
			
		||||
          <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>
 | 
			
		||||
        </div>
 | 
			
		||||
      </ha-md-dialog>
 | 
			
		||||
      <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>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    ha-md-dialog {
 | 
			
		||||
      min-width: 400px;
 | 
			
		||||
      max-height: 90%;
 | 
			
		||||
      --dialog-content-padding: var(--ha-space-2) var(--ha-space-6)
 | 
			
		||||
        max(var(--safe-area-inset-bottom, var(--ha-space-0)), var(--ha-space-8));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media all and (max-width: 600px), all and (max-height: 500px) {
 | 
			
		||||
      ha-md-dialog {
 | 
			
		||||
        --md-dialog-container-shape: var(--ha-space-0);
 | 
			
		||||
        min-width: 100%;
 | 
			
		||||
        min-height: 100%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
 
 | 
			
		||||
@@ -162,11 +162,12 @@ export class HaTargetPickerItemRow extends LitElement {
 | 
			
		||||
        <div slot="headline">${name}</div>
 | 
			
		||||
        ${context && !this.hideContext
 | 
			
		||||
          ? html`<span slot="supporting-text">${context}</span>`
 | 
			
		||||
          : this._domainName && this.subEntry
 | 
			
		||||
            ? html`<span slot="supporting-text" class="domain"
 | 
			
		||||
                >${this._domainName}</span
 | 
			
		||||
              >`
 | 
			
		||||
            : nothing}
 | 
			
		||||
          : 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">
 | 
			
		||||
@@ -231,9 +232,11 @@ export class HaTargetPickerItemRow extends LitElement {
 | 
			
		||||
    const rows1 =
 | 
			
		||||
      (nextType === "area"
 | 
			
		||||
        ? entries?.referenced_areas
 | 
			
		||||
        : nextType === "device"
 | 
			
		||||
        : nextType === "device" && this.type !== "label"
 | 
			
		||||
          ? entries?.referenced_devices
 | 
			
		||||
          : entries?.referenced_entities) || [];
 | 
			
		||||
          : this.type !== "label"
 | 
			
		||||
            ? entries?.referenced_entities
 | 
			
		||||
            : []) || [];
 | 
			
		||||
 | 
			
		||||
    const devicesInAreas = [] as string[];
 | 
			
		||||
 | 
			
		||||
@@ -284,9 +287,13 @@ export class HaTargetPickerItemRow extends LitElement {
 | 
			
		||||
 | 
			
		||||
    const entityRows =
 | 
			
		||||
      this.type === "label" && entries
 | 
			
		||||
        ? entries.referenced_entities.filter((entity_id) =>
 | 
			
		||||
            this.hass.entities[entity_id].labels.includes(this.itemId)
 | 
			
		||||
          )
 | 
			
		||||
        ? 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) =>
 | 
			
		||||
@@ -412,7 +419,6 @@ export class HaTargetPickerItemRow extends LitElement {
 | 
			
		||||
            const device = this.hass.devices[device_id];
 | 
			
		||||
            if (
 | 
			
		||||
              !hiddenAreaIds.includes(device.area_id || "") &&
 | 
			
		||||
              (this.type !== "label" || device.labels.includes(this.itemId)) &&
 | 
			
		||||
              deviceMeetsFilter(
 | 
			
		||||
                device,
 | 
			
		||||
                this.hass.entities,
 | 
			
		||||
@@ -669,6 +675,14 @@ export class HaTargetPickerItemRow extends LitElement {
 | 
			
		||||
      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);
 | 
			
		||||
      }
 | 
			
		||||
    `,
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,13 +16,10 @@ 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 {
 | 
			
		||||
  computeDeviceName,
 | 
			
		||||
  computeDeviceNameDisplay,
 | 
			
		||||
} from "../../common/entity/compute_device_name";
 | 
			
		||||
import { 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 { computeStateName } from "../../common/entity/compute_state_name";
 | 
			
		||||
import { slugify } from "../../common/string/slugify";
 | 
			
		||||
import { getConfigEntry } from "../../data/config_entries";
 | 
			
		||||
import { labelsContext } from "../../data/context";
 | 
			
		||||
import { domainToName } from "../../data/integration";
 | 
			
		||||
@@ -102,7 +99,7 @@ export class HaTargetPickerValueChip extends LitElement {
 | 
			
		||||
        ${this.type === "entity"
 | 
			
		||||
          ? nothing
 | 
			
		||||
          : html`<span role="gridcell">
 | 
			
		||||
              <ha-tooltip .for="expand-${this.itemId}"
 | 
			
		||||
              <ha-tooltip .for="expand-${slugify(this.itemId)}"
 | 
			
		||||
                >${this.hass.localize(
 | 
			
		||||
                  `ui.components.target-picker.expand_${this.type}_id`
 | 
			
		||||
                )}
 | 
			
		||||
@@ -114,13 +111,13 @@ export class HaTargetPickerValueChip extends LitElement {
 | 
			
		||||
                )}
 | 
			
		||||
                .path=${mdiUnfoldMoreVertical}
 | 
			
		||||
                hide-title
 | 
			
		||||
                .id="expand-${this.itemId}"
 | 
			
		||||
                .id="expand-${slugify(this.itemId)}"
 | 
			
		||||
                .type=${this.type}
 | 
			
		||||
                @click=${this._handleExpand}
 | 
			
		||||
              ></ha-icon-button>
 | 
			
		||||
            </span>`}
 | 
			
		||||
        <span role="gridcell">
 | 
			
		||||
          <ha-tooltip .for="remove-${this.itemId}">
 | 
			
		||||
          <ha-tooltip .for="remove-${slugify(this.itemId)}">
 | 
			
		||||
            ${this.hass.localize(
 | 
			
		||||
              `ui.components.target-picker.remove_${this.type}_id`
 | 
			
		||||
            )}
 | 
			
		||||
@@ -130,7 +127,7 @@ export class HaTargetPickerValueChip extends LitElement {
 | 
			
		||||
            .label=${this.hass.localize("ui.components.target-picker.remove")}
 | 
			
		||||
            .path=${mdiClose}
 | 
			
		||||
            hide-title
 | 
			
		||||
            .id="remove-${this.itemId}"
 | 
			
		||||
            .id="remove-${slugify(this.itemId)}"
 | 
			
		||||
            .type=${this.type}
 | 
			
		||||
            @click=${this._removeItem}
 | 
			
		||||
          ></ha-icon-button>
 | 
			
		||||
@@ -171,23 +168,10 @@ export class HaTargetPickerValueChip extends LitElement {
 | 
			
		||||
    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;
 | 
			
		||||
      const stateObj = this.hass.states[itemId];
 | 
			
		||||
      return {
 | 
			
		||||
        name: entityName || deviceName || itemId,
 | 
			
		||||
        stateObject,
 | 
			
		||||
        name: computeStateName(stateObj) || itemId,
 | 
			
		||||
        stateObject: stateObj,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -6,8 +6,6 @@ import {
 | 
			
		||||
  mdiCallSplit,
 | 
			
		||||
  mdiCodeBraces,
 | 
			
		||||
  mdiDevices,
 | 
			
		||||
  mdiDotsHorizontal,
 | 
			
		||||
  mdiExcavator,
 | 
			
		||||
  mdiFormatListNumbered,
 | 
			
		||||
  mdiGestureDoubleTap,
 | 
			
		||||
  mdiHandBackRight,
 | 
			
		||||
@@ -16,10 +14,10 @@ import {
 | 
			
		||||
  mdiRoomService,
 | 
			
		||||
  mdiShuffleDisabled,
 | 
			
		||||
  mdiTimerOutline,
 | 
			
		||||
  mdiTools,
 | 
			
		||||
  mdiTrafficLight,
 | 
			
		||||
} from "@mdi/js";
 | 
			
		||||
import type { AutomationElementGroup } from "./automation";
 | 
			
		||||
import type { AutomationElementGroupCollection } from "./automation";
 | 
			
		||||
import type { Action } from "./script";
 | 
			
		||||
 | 
			
		||||
export const ACTION_ICONS = {
 | 
			
		||||
  condition: mdiAbTesting,
 | 
			
		||||
@@ -48,37 +46,73 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
 | 
			
		||||
  "variables",
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const ACTION_GROUPS: AutomationElementGroup = {
 | 
			
		||||
  device_id: {},
 | 
			
		||||
  helpers: {
 | 
			
		||||
    icon: mdiTools,
 | 
			
		||||
    members: {},
 | 
			
		||||
  },
 | 
			
		||||
  building_blocks: {
 | 
			
		||||
    icon: mdiExcavator,
 | 
			
		||||
    members: {
 | 
			
		||||
      condition: {},
 | 
			
		||||
      delay: {},
 | 
			
		||||
      wait_template: {},
 | 
			
		||||
      wait_for_trigger: {},
 | 
			
		||||
      repeat_count: {},
 | 
			
		||||
      repeat_while: {},
 | 
			
		||||
      repeat_until: {},
 | 
			
		||||
      repeat_for_each: {},
 | 
			
		||||
      choose: {},
 | 
			
		||||
      if: {},
 | 
			
		||||
      stop: {},
 | 
			
		||||
      sequence: {},
 | 
			
		||||
      parallel: {},
 | 
			
		||||
      variables: {},
 | 
			
		||||
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
 | 
			
		||||
  {
 | 
			
		||||
    groups: {
 | 
			
		||||
      device_id: {},
 | 
			
		||||
      serviceGroups: {},
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  other: {
 | 
			
		||||
    icon: mdiDotsHorizontal,
 | 
			
		||||
    members: {
 | 
			
		||||
  {
 | 
			
		||||
    titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
 | 
			
		||||
    groups: {
 | 
			
		||||
      helpers: {},
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
 | 
			
		||||
    groups: {
 | 
			
		||||
      event: {},
 | 
			
		||||
      service: {},
 | 
			
		||||
      set_conversation_response: {},
 | 
			
		||||
      other: {},
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const ACTION_BUILDING_BLOCKS_GROUP = {
 | 
			
		||||
  condition: {},
 | 
			
		||||
  delay: {},
 | 
			
		||||
  wait_template: {},
 | 
			
		||||
  wait_for_trigger: {},
 | 
			
		||||
  repeat_count: {},
 | 
			
		||||
  repeat_while: {},
 | 
			
		||||
  repeat_until: {},
 | 
			
		||||
  repeat_for_each: {},
 | 
			
		||||
  choose: {},
 | 
			
		||||
  if: {},
 | 
			
		||||
  stop: {},
 | 
			
		||||
  sequence: {},
 | 
			
		||||
  parallel: {},
 | 
			
		||||
  variables: {},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// These will be replaced with the correct action
 | 
			
		||||
export const VIRTUAL_ACTIONS: Partial<
 | 
			
		||||
  Record<keyof typeof ACTION_BUILDING_BLOCKS_GROUP, Action>
 | 
			
		||||
> = {
 | 
			
		||||
  repeat_count: {
 | 
			
		||||
    repeat: {
 | 
			
		||||
      count: 2,
 | 
			
		||||
      sequence: [],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  repeat_while: {
 | 
			
		||||
    repeat: {
 | 
			
		||||
      while: [],
 | 
			
		||||
      sequence: [],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  repeat_until: {
 | 
			
		||||
    repeat: {
 | 
			
		||||
      until: [],
 | 
			
		||||
      sequence: [],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  repeat_for_each: {
 | 
			
		||||
    repeat: {
 | 
			
		||||
      for_each: {},
 | 
			
		||||
      sequence: [],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
} as const;
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import type {
 | 
			
		||||
} from "home-assistant-js-websocket";
 | 
			
		||||
import { ensureArray } from "../common/array/ensure-array";
 | 
			
		||||
import { navigate } from "../common/navigate";
 | 
			
		||||
import type { LocalizeKeys } from "../common/translations/localize";
 | 
			
		||||
import { createSearchParam } from "../common/url/search-params";
 | 
			
		||||
import type { Context, HomeAssistant } from "../types";
 | 
			
		||||
import type { BlueprintInput } from "./blueprint";
 | 
			
		||||
@@ -293,6 +294,11 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
 | 
			
		||||
  not: Condition[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AutomationElementGroupCollection {
 | 
			
		||||
  titleKey?: LocalizeKeys;
 | 
			
		||||
  groups: AutomationElementGroup;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type AutomationElementGroup = Record<
 | 
			
		||||
  string,
 | 
			
		||||
  { icon?: string; members?: AutomationElementGroup }
 | 
			
		||||
 
 | 
			
		||||
@@ -3,8 +3,6 @@ import {
 | 
			
		||||
  mdiClockOutline,
 | 
			
		||||
  mdiCodeBraces,
 | 
			
		||||
  mdiDevices,
 | 
			
		||||
  mdiDotsHorizontal,
 | 
			
		||||
  mdiExcavator,
 | 
			
		||||
  mdiGateOr,
 | 
			
		||||
  mdiIdentifier,
 | 
			
		||||
  mdiMapClock,
 | 
			
		||||
@@ -15,7 +13,7 @@ import {
 | 
			
		||||
  mdiStateMachine,
 | 
			
		||||
  mdiWeatherSunny,
 | 
			
		||||
} from "@mdi/js";
 | 
			
		||||
import type { AutomationElementGroup } from "./automation";
 | 
			
		||||
import type { AutomationElementGroupCollection } from "./automation";
 | 
			
		||||
 | 
			
		||||
export const CONDITION_ICONS = {
 | 
			
		||||
  device: mdiDevices,
 | 
			
		||||
@@ -31,25 +29,31 @@ export const CONDITION_ICONS = {
 | 
			
		||||
  zone: mdiMapMarkerRadius,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const CONDITION_GROUPS: AutomationElementGroup = {
 | 
			
		||||
  device: {},
 | 
			
		||||
  entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
 | 
			
		||||
  time_location: {
 | 
			
		||||
    icon: mdiMapClock,
 | 
			
		||||
    members: { sun: {}, time: {}, zone: {} },
 | 
			
		||||
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
 | 
			
		||||
  {
 | 
			
		||||
    groups: {
 | 
			
		||||
      device: {},
 | 
			
		||||
      entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
 | 
			
		||||
      time_location: {
 | 
			
		||||
        icon: mdiMapClock,
 | 
			
		||||
        members: { sun: {}, time: {}, zone: {} },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  building_blocks: {
 | 
			
		||||
    icon: mdiExcavator,
 | 
			
		||||
    members: { and: {}, or: {}, not: {} },
 | 
			
		||||
  },
 | 
			
		||||
  other: {
 | 
			
		||||
    icon: mdiDotsHorizontal,
 | 
			
		||||
    members: {
 | 
			
		||||
  {
 | 
			
		||||
    titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
 | 
			
		||||
    groups: {
 | 
			
		||||
      template: {},
 | 
			
		||||
      trigger: {},
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
} as const;
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const CONDITION_BUILDING_BLOCKS_GROUP = {
 | 
			
		||||
  and: {},
 | 
			
		||||
  or: {},
 | 
			
		||||
  not: {},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -76,7 +76,7 @@ export const floorCompare =
 | 
			
		||||
      const floorA = entries?.[a];
 | 
			
		||||
      const floorB = entries?.[b];
 | 
			
		||||
      if (floorA && floorB && floorA.level !== floorB.level) {
 | 
			
		||||
        return (floorA.level ?? 9999) - (floorB.level ?? 9999);
 | 
			
		||||
        return (floorB.level ?? -9999) - (floorA.level ?? -9999);
 | 
			
		||||
      }
 | 
			
		||||
      const nameA = floorA?.name ?? a;
 | 
			
		||||
      const nameB = floorB?.name ?? b;
 | 
			
		||||
 
 | 
			
		||||
@@ -435,9 +435,9 @@ export const convertStatisticsToHistory = (
 | 
			
		||||
  Object.entries(orderedStatistics).forEach(([key, value]) => {
 | 
			
		||||
    const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
 | 
			
		||||
      s: e.mean != null ? e.mean.toString() : e.state!.toString(),
 | 
			
		||||
      lc: e.start / 1000,
 | 
			
		||||
      lc: e.end / 1000,
 | 
			
		||||
      a: {},
 | 
			
		||||
      lu: e.start / 1000,
 | 
			
		||||
      lu: e.end / 1000,
 | 
			
		||||
    }));
 | 
			
		||||
    statsHistoryStates[key] = entityHistoryStates;
 | 
			
		||||
  });
 | 
			
		||||
 
 | 
			
		||||
@@ -264,6 +264,7 @@ export const getLabels = (
 | 
			
		||||
  const items = outputLabels.map<PickerComboBoxItem>((label) => ({
 | 
			
		||||
    id: label.label_id,
 | 
			
		||||
    primary: label.name,
 | 
			
		||||
    secondary: label.description ?? "",
 | 
			
		||||
    icon: label.icon || undefined,
 | 
			
		||||
    icon_path: label.icon ? undefined : mdiLabel,
 | 
			
		||||
    sorting_label: label.name,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import type { MediaSelectorValue } from "../../selector";
 | 
			
		||||
import type { LovelaceBadgeConfig } from "./badge";
 | 
			
		||||
import type { LovelaceCardConfig } from "./card";
 | 
			
		||||
import type { LovelaceSectionRawConfig } from "./section";
 | 
			
		||||
@@ -8,7 +9,7 @@ export interface ShowViewConfig {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface LovelaceViewBackgroundConfig {
 | 
			
		||||
  image?: string;
 | 
			
		||||
  image?: string | MediaSelectorValue;
 | 
			
		||||
  opacity?: number;
 | 
			
		||||
  size?: "auto" | "cover" | "contain";
 | 
			
		||||
  alignment?:
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@ import type {
 | 
			
		||||
import { ensureArray } from "../common/array/ensure-array";
 | 
			
		||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
 | 
			
		||||
import { supportsFeature } from "../common/entity/supports-feature";
 | 
			
		||||
import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog";
 | 
			
		||||
import { isHelperDomain } from "../panels/config/helpers/const";
 | 
			
		||||
import type { UiAction } from "../panels/lovelace/components/hui-action-editor";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
@@ -47,8 +46,6 @@ export type Selector =
 | 
			
		||||
  | FileSelector
 | 
			
		||||
  | IconSelector
 | 
			
		||||
  | LabelSelector
 | 
			
		||||
  | ImageSelector
 | 
			
		||||
  | BackgroundSelector
 | 
			
		||||
  | LanguageSelector
 | 
			
		||||
  | LocationSelector
 | 
			
		||||
  | MediaSelector
 | 
			
		||||
@@ -273,14 +270,6 @@ export interface IconSelector {
 | 
			
		||||
  } | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ImageSelector {
 | 
			
		||||
  image: { original?: boolean; crop?: CropOptions } | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface BackgroundSelector {
 | 
			
		||||
  background: { original?: boolean; crop?: CropOptions } | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface LabelSelector {
 | 
			
		||||
  label: {
 | 
			
		||||
    multiple?: boolean;
 | 
			
		||||
@@ -352,6 +341,7 @@ export interface NumberSelector {
 | 
			
		||||
interface ObjectSelectorField {
 | 
			
		||||
  selector: Selector;
 | 
			
		||||
  label?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  required?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ import {
 | 
			
		||||
  mdiClockOutline,
 | 
			
		||||
  mdiCodeBraces,
 | 
			
		||||
  mdiDevices,
 | 
			
		||||
  mdiDotsHorizontal,
 | 
			
		||||
  mdiFormatListBulleted,
 | 
			
		||||
  mdiGestureDoubleTap,
 | 
			
		||||
  mdiMapClock,
 | 
			
		||||
@@ -23,7 +22,7 @@ import {
 | 
			
		||||
 | 
			
		||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
 | 
			
		||||
import type {
 | 
			
		||||
  AutomationElementGroup,
 | 
			
		||||
  AutomationElementGroupCollection,
 | 
			
		||||
  Trigger,
 | 
			
		||||
  TriggerList,
 | 
			
		||||
} from "./automation";
 | 
			
		||||
@@ -49,16 +48,26 @@ export const TRIGGER_ICONS = {
 | 
			
		||||
  list: mdiFormatListBulleted,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const TRIGGER_GROUPS: AutomationElementGroup = {
 | 
			
		||||
  device: {},
 | 
			
		||||
  entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
 | 
			
		||||
  time_location: {
 | 
			
		||||
    icon: mdiMapClock,
 | 
			
		||||
    members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} },
 | 
			
		||||
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
 | 
			
		||||
  {
 | 
			
		||||
    groups: {
 | 
			
		||||
      device: {},
 | 
			
		||||
      entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
 | 
			
		||||
      time_location: {
 | 
			
		||||
        icon: mdiMapClock,
 | 
			
		||||
        members: {
 | 
			
		||||
          calendar: {},
 | 
			
		||||
          sun: {},
 | 
			
		||||
          time: {},
 | 
			
		||||
          time_pattern: {},
 | 
			
		||||
          zone: {},
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  other: {
 | 
			
		||||
    icon: mdiDotsHorizontal,
 | 
			
		||||
    members: {
 | 
			
		||||
  {
 | 
			
		||||
    titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label",
 | 
			
		||||
    groups: {
 | 
			
		||||
      event: {},
 | 
			
		||||
      geo_location: {},
 | 
			
		||||
      homeassistant: {},
 | 
			
		||||
@@ -70,7 +79,7 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
 | 
			
		||||
      persistent_notification: {},
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
} as const;
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
 | 
			
		||||
  "triggers" in trigger;
 | 
			
		||||
 
 | 
			
		||||
@@ -484,7 +484,7 @@ class DataEntryFlowDialog extends LitElement {
 | 
			
		||||
        this._unsubDataEntryFlowProgress = undefined;
 | 
			
		||||
      }
 | 
			
		||||
      if (_step.next_flow[0] === "config_flow") {
 | 
			
		||||
        showConfigFlowDialog(this._params!.dialogParentElement!, {
 | 
			
		||||
        showConfigFlowDialog(this, {
 | 
			
		||||
          continueFlowId: _step.next_flow[1],
 | 
			
		||||
          carryOverDevices: this._devices(
 | 
			
		||||
            this._params!.flowConfig.showDevices,
 | 
			
		||||
@@ -496,32 +496,23 @@ class DataEntryFlowDialog extends LitElement {
 | 
			
		||||
        });
 | 
			
		||||
      } else if (_step.next_flow[0] === "options_flow") {
 | 
			
		||||
        if (_step.type === "create_entry") {
 | 
			
		||||
          showOptionsFlowDialog(
 | 
			
		||||
            this._params!.dialogParentElement!,
 | 
			
		||||
            _step.result!,
 | 
			
		||||
            {
 | 
			
		||||
              continueFlowId: _step.next_flow[1],
 | 
			
		||||
              navigateToResult: this._params!.navigateToResult,
 | 
			
		||||
              dialogClosedCallback: this._params!.dialogClosedCallback,
 | 
			
		||||
            }
 | 
			
		||||
          );
 | 
			
		||||
          showOptionsFlowDialog(this, _step.result!, {
 | 
			
		||||
            continueFlowId: _step.next_flow[1],
 | 
			
		||||
            navigateToResult: this._params!.navigateToResult,
 | 
			
		||||
            dialogClosedCallback: this._params!.dialogClosedCallback,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      } else if (_step.next_flow[0] === "config_subentries_flow") {
 | 
			
		||||
        if (_step.type === "create_entry") {
 | 
			
		||||
          showSubConfigFlowDialog(
 | 
			
		||||
            this._params!.dialogParentElement!,
 | 
			
		||||
            _step.result!,
 | 
			
		||||
            _step.next_flow[0],
 | 
			
		||||
            {
 | 
			
		||||
              continueFlowId: _step.next_flow[1],
 | 
			
		||||
              navigateToResult: this._params!.navigateToResult,
 | 
			
		||||
              dialogClosedCallback: this._params!.dialogClosedCallback,
 | 
			
		||||
            }
 | 
			
		||||
          );
 | 
			
		||||
          showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], {
 | 
			
		||||
            continueFlowId: _step.next_flow[1],
 | 
			
		||||
            navigateToResult: this._params!.navigateToResult,
 | 
			
		||||
            dialogClosedCallback: this._params!.dialogClosedCallback,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        this.closeDialog();
 | 
			
		||||
        showAlertDialog(this._params!.dialogParentElement!, {
 | 
			
		||||
        showAlertDialog(this, {
 | 
			
		||||
          text: this.hass.localize(
 | 
			
		||||
            "ui.panel.config.integrations.config_flow.error",
 | 
			
		||||
            { error: `Unsupported next flow type: ${_step.next_flow[0]}` }
 | 
			
		||||
 
 | 
			
		||||
@@ -678,8 +678,8 @@ export class MoreInfoDialog extends LitElement {
 | 
			
		||||
          /* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
 | 
			
		||||
          --vertical-align-dialog: flex-start;
 | 
			
		||||
          --dialog-surface-margin-top: max(
 | 
			
		||||
            40px,
 | 
			
		||||
            var(--safe-area-inset-top, 0px)
 | 
			
		||||
            var(--ha-space-10),
 | 
			
		||||
            var(--safe-area-inset-top, var(--ha-space-0))
 | 
			
		||||
          );
 | 
			
		||||
          --dialog-content-padding: 0;
 | 
			
		||||
        }
 | 
			
		||||
@@ -698,14 +698,15 @@ export class MoreInfoDialog extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-more-info-history-and-logbook {
 | 
			
		||||
          padding: 8px 24px 24px 24px;
 | 
			
		||||
          padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
 | 
			
		||||
            var(--ha-space-6);
 | 
			
		||||
          display: block;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @media all and (max-width: 450px), all and (max-height: 500px) {
 | 
			
		||||
          /* When in fullscreen dialog should be attached to top */
 | 
			
		||||
          ha-dialog {
 | 
			
		||||
            --dialog-surface-margin-top: 0px;
 | 
			
		||||
            --dialog-surface-margin-top: var(--ha-space-0);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -730,7 +731,8 @@ export class MoreInfoDialog extends LitElement {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          flex-direction: column;
 | 
			
		||||
          align-items: flex-start;
 | 
			
		||||
          margin: 0 0 -10px 0;
 | 
			
		||||
          margin: var(--ha-space-0) var(--ha-space-0)
 | 
			
		||||
            calc(var(--ha-space-2) * -1) var(--ha-space-0);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .title p {
 | 
			
		||||
@@ -752,9 +754,9 @@ export class MoreInfoDialog extends LitElement {
 | 
			
		||||
          font-size: var(--ha-font-size-m);
 | 
			
		||||
          line-height: 16px;
 | 
			
		||||
          --mdc-icon-size: 16px;
 | 
			
		||||
          padding: 4px;
 | 
			
		||||
          margin: -4px;
 | 
			
		||||
          margin-top: -10px;
 | 
			
		||||
          padding: var(--ha-space-1);
 | 
			
		||||
          margin: calc(var(--ha-space-1) * -1);
 | 
			
		||||
          margin-top: calc(var(--ha-space-2) * -1);
 | 
			
		||||
          background: none;
 | 
			
		||||
          border: none;
 | 
			
		||||
          outline: none;
 | 
			
		||||
 
 | 
			
		||||
@@ -1011,8 +1011,8 @@ export class QuickBar extends LitElement {
 | 
			
		||||
            --mdc-dialog-max-width: 800px;
 | 
			
		||||
            --mdc-dialog-min-width: 500px;
 | 
			
		||||
            --dialog-surface-position: fixed;
 | 
			
		||||
            --dialog-surface-top: 40px;
 | 
			
		||||
            --mdc-dialog-max-height: calc(100% - 72px);
 | 
			
		||||
            --dialog-surface-top: var(--ha-space-10);
 | 
			
		||||
            --mdc-dialog-max-height: calc(100% - var(--ha-space-18));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -1055,8 +1055,8 @@ export class QuickBar extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        span.command-text {
 | 
			
		||||
          margin-left: 8px;
 | 
			
		||||
          margin-inline-start: 8px;
 | 
			
		||||
          margin-left: var(--ha-space-2);
 | 
			
		||||
          margin-inline-start: var(--ha-space-2);
 | 
			
		||||
          margin-inline-end: initial;
 | 
			
		||||
          direction: var(--direction);
 | 
			
		||||
        }
 | 
			
		||||
@@ -1069,8 +1069,8 @@ export class QuickBar extends LitElement {
 | 
			
		||||
        ha-md-list-item.two-line {
 | 
			
		||||
          --md-list-item-one-line-container-height: 64px;
 | 
			
		||||
          --md-list-item-two-line-container-height: 64px;
 | 
			
		||||
          --md-list-item-top-space: 8px;
 | 
			
		||||
          --md-list-item-bottom-space: 8px;
 | 
			
		||||
          --md-list-item-top-space: var(--ha-space-2);
 | 
			
		||||
          --md-list-item-bottom-space: var(--ha-space-2);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-md-list-item.three-line {
 | 
			
		||||
@@ -1078,8 +1078,8 @@ export class QuickBar extends LitElement {
 | 
			
		||||
          --md-list-item-one-line-container-height: 72px;
 | 
			
		||||
          --md-list-item-two-line-container-height: 72px;
 | 
			
		||||
          --md-list-item-three-line-container-height: 72px;
 | 
			
		||||
          --md-list-item-top-space: 8px;
 | 
			
		||||
          --md-list-item-bottom-space: 8px;
 | 
			
		||||
          --md-list-item-top-space: var(--ha-space-2);
 | 
			
		||||
          --md-list-item-bottom-space: var(--ha-space-2);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-md-list-item .code {
 | 
			
		||||
@@ -1104,11 +1104,11 @@ export class QuickBar extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ha-tip {
 | 
			
		||||
          padding: 20px;
 | 
			
		||||
          padding: var(--ha-space-5);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .nothing-found {
 | 
			
		||||
          padding: 16px 0px;
 | 
			
		||||
          padding: var(--ha-space-4) var(--ha-space-0);
 | 
			
		||||
          text-align: center;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -193,12 +193,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
 | 
			
		||||
                    ).map(
 | 
			
		||||
                      (lang) =>
 | 
			
		||||
                        html`<ha-md-menu-item
 | 
			
		||||
                          .value=${lang.value}
 | 
			
		||||
                          .value=${lang.id}
 | 
			
		||||
                          @click=${this._handlePickLanguage}
 | 
			
		||||
                          @keydown=${this._handlePickLanguage}
 | 
			
		||||
                          .selected=${this._language === lang.value}
 | 
			
		||||
                          .selected=${this._language === lang.id}
 | 
			
		||||
                        >
 | 
			
		||||
                          ${lang.label}
 | 
			
		||||
                          ${lang.primary}
 | 
			
		||||
                        </ha-md-menu-item>`
 | 
			
		||||
                    )}
 | 
			
		||||
                  </ha-md-button-menu>`
 | 
			
		||||
 
 | 
			
		||||
@@ -97,6 +97,9 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
 | 
			
		||||
    pm25: {
 | 
			
		||||
      default: "mdi:molecule",
 | 
			
		||||
    },
 | 
			
		||||
    pm4: {
 | 
			
		||||
      default: "mdi:molecule",
 | 
			
		||||
    },
 | 
			
		||||
    power: {
 | 
			
		||||
      default: "mdi:flash",
 | 
			
		||||
    },
 | 
			
		||||
@@ -674,6 +677,9 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
 | 
			
		||||
    pm25: {
 | 
			
		||||
      default: "mdi:molecule",
 | 
			
		||||
    },
 | 
			
		||||
    pm4: {
 | 
			
		||||
      default: "mdi:molecule",
 | 
			
		||||
    },
 | 
			
		||||
    power: {
 | 
			
		||||
      default: "mdi:flash",
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||
@@ -37,7 +37,6 @@
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        view-transition-name: layout-fade-out;
 | 
			
		||||
      }
 | 
			
		||||
      #ha-launch-screen svg {
 | 
			
		||||
        width: 112px;
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,6 @@ class HassLoadingScreen extends LitElement {
 | 
			
		||||
          display: block;
 | 
			
		||||
          height: 100%;
 | 
			
		||||
          background-color: var(--primary-background-color);
 | 
			
		||||
          view-transition-name: layout-fade-out;
 | 
			
		||||
        }
 | 
			
		||||
        .toolbar {
 | 
			
		||||
          display: flex;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@ import { ReactiveElement } from "lit";
 | 
			
		||||
import { property } from "lit/decorators";
 | 
			
		||||
import memoizeOne from "memoize-one";
 | 
			
		||||
import { navigate } from "../common/navigate";
 | 
			
		||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
 | 
			
		||||
import type { Route } from "../types";
 | 
			
		||||
 | 
			
		||||
const extractPage = (path: string, defaultPage: string) => {
 | 
			
		||||
@@ -44,7 +43,7 @@ export interface RouterOptions {
 | 
			
		||||
// Time to wait for code to load before we show loading screen.
 | 
			
		||||
const LOADING_SCREEN_THRESHOLD = 400; // ms
 | 
			
		||||
 | 
			
		||||
export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) {
 | 
			
		||||
export class HassRouterPage extends ReactiveElement {
 | 
			
		||||
  @property({ attribute: false }) public route?: Route;
 | 
			
		||||
 | 
			
		||||
  protected routerOptions!: RouterOptions;
 | 
			
		||||
@@ -311,18 +310,16 @@ export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) {
 | 
			
		||||
    page: string,
 | 
			
		||||
    routeOptions: RouteOptions
 | 
			
		||||
  ) {
 | 
			
		||||
    this.startViewTransition(() => {
 | 
			
		||||
      if (this.lastChild) {
 | 
			
		||||
        this.removeChild(this.lastChild);
 | 
			
		||||
      }
 | 
			
		||||
    if (this.lastChild) {
 | 
			
		||||
      this.removeChild(this.lastChild);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
      const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
 | 
			
		||||
      this.updatePageEl(panelEl);
 | 
			
		||||
      this.appendChild(panelEl);
 | 
			
		||||
    const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
 | 
			
		||||
    this.updatePageEl(panelEl);
 | 
			
		||||
    this.appendChild(panelEl);
 | 
			
		||||
 | 
			
		||||
      if (routerOptions.cacheAll || routeOptions.cache) {
 | 
			
		||||
        this._cache[page] = panelEl;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    if (routerOptions.cacheAll || routeOptions.cache) {
 | 
			
		||||
      this._cache[page] = panelEl;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,15 @@
 | 
			
		||||
import type { CSSResultGroup, TemplateResult } from "lit";
 | 
			
		||||
import { css, html, LitElement } from "lit";
 | 
			
		||||
import { customElement, eventOptions, property } from "lit/decorators";
 | 
			
		||||
import { classMap } from "lit/directives/class-map";
 | 
			
		||||
import { restoreScroll } from "../common/decorators/restore-scroll";
 | 
			
		||||
import { goBack } from "../common/navigate";
 | 
			
		||||
import "../components/ha-icon-button-arrow-prev";
 | 
			
		||||
import "../components/ha-menu-button";
 | 
			
		||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
 | 
			
		||||
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
 | 
			
		||||
import { haStyleScrollbar } from "../resources/styles";
 | 
			
		||||
import type { HomeAssistant } from "../types";
 | 
			
		||||
 | 
			
		||||
@customElement("hass-subpage")
 | 
			
		||||
class HassSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
class HassSubpage extends LitElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
 | 
			
		||||
  @property() public header?: string;
 | 
			
		||||
@@ -62,14 +60,7 @@ class HassSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
          <slot name="toolbar-icon"></slot>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        class=${classMap({
 | 
			
		||||
          content: true,
 | 
			
		||||
          "ha-scrollbar": true,
 | 
			
		||||
          loading: !this._loaded,
 | 
			
		||||
        })}
 | 
			
		||||
        @scroll=${this._saveScrollPos}
 | 
			
		||||
      >
 | 
			
		||||
      <div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
 | 
			
		||||
        <slot></slot>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="fab">
 | 
			
		||||
@@ -94,7 +85,6 @@ class HassSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
  static get styles(): CSSResultGroup {
 | 
			
		||||
    return [
 | 
			
		||||
      haStyleScrollbar,
 | 
			
		||||
      haStyleViewTransitions,
 | 
			
		||||
      css`
 | 
			
		||||
        :host {
 | 
			
		||||
          display: block;
 | 
			
		||||
@@ -177,10 +167,6 @@ class HassSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
          overflow-y: auto;
 | 
			
		||||
          overflow: auto;
 | 
			
		||||
          -webkit-overflow-scrolling: touch;
 | 
			
		||||
          view-transition-name: layout-fade-in;
 | 
			
		||||
        }
 | 
			
		||||
        .content.loading {
 | 
			
		||||
          opacity: 0;
 | 
			
		||||
        }
 | 
			
		||||
        :host([narrow]) .content {
 | 
			
		||||
          width: calc(
 | 
			
		||||
 
 | 
			
		||||
@@ -11,8 +11,7 @@ import "../components/ha-icon-button-arrow-prev";
 | 
			
		||||
import "../components/ha-menu-button";
 | 
			
		||||
import "../components/ha-svg-icon";
 | 
			
		||||
import "../components/ha-tab";
 | 
			
		||||
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
 | 
			
		||||
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
 | 
			
		||||
import { haStyleScrollbar } from "../resources/styles";
 | 
			
		||||
import type { HomeAssistant, Route } from "../types";
 | 
			
		||||
 | 
			
		||||
export interface PageNavigation {
 | 
			
		||||
@@ -30,7 +29,7 @@ export interface PageNavigation {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@customElement("hass-tabs-subpage")
 | 
			
		||||
class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
class HassTabsSubpage extends LitElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public supervisor = false;
 | 
			
		||||
@@ -186,12 +185,7 @@ class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
            </div>`
 | 
			
		||||
          : nothing}
 | 
			
		||||
        <div
 | 
			
		||||
          class=${classMap({
 | 
			
		||||
            content: true,
 | 
			
		||||
            "ha-scrollbar": true,
 | 
			
		||||
            tabs: showTabs,
 | 
			
		||||
            loading: !this._loaded,
 | 
			
		||||
          })}
 | 
			
		||||
          class="content ha-scrollbar ${classMap({ tabs: showTabs })}"
 | 
			
		||||
          @scroll=${this._saveScrollPos}
 | 
			
		||||
        >
 | 
			
		||||
          <slot></slot>
 | 
			
		||||
@@ -220,7 +214,6 @@ class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
  static get styles(): CSSResultGroup {
 | 
			
		||||
    return [
 | 
			
		||||
      haStyleScrollbar,
 | 
			
		||||
      haStyleViewTransitions,
 | 
			
		||||
      css`
 | 
			
		||||
        :host {
 | 
			
		||||
          display: block;
 | 
			
		||||
@@ -339,10 +332,6 @@ class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
          margin-bottom: var(--safe-area-inset-bottom);
 | 
			
		||||
          overflow: auto;
 | 
			
		||||
          -webkit-overflow-scrolling: touch;
 | 
			
		||||
          view-transition-name: layout-fade-in;
 | 
			
		||||
        }
 | 
			
		||||
        .content.loading {
 | 
			
		||||
          opacity: 0;
 | 
			
		||||
        }
 | 
			
		||||
        :host([narrow]) .content {
 | 
			
		||||
          margin-left: var(--safe-area-inset-left);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,201 +0,0 @@
 | 
			
		||||
import type { PropertyValues, ReactiveElement } from "lit";
 | 
			
		||||
import { state } from "lit/decorators";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Abstract constructor type for a class that extends a reactive element
 | 
			
		||||
 * @param T - The type of the reactive element
 | 
			
		||||
 * @returns The abstract constructor
 | 
			
		||||
 */
 | 
			
		||||
type AbstractConstructor<T extends ReactiveElement> = abstract new (
 | 
			
		||||
  ...args: any[]
 | 
			
		||||
) => T;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * ViewTransitionMixin - Adds view transition support to reactive elements
 | 
			
		||||
 *
 | 
			
		||||
 * This mixin provides automatic fade-in transitions when content loads using the
 | 
			
		||||
 * View Transition API. User preferences are respected for reduced motion.
 | 
			
		||||
 * Falls back to synchronous updates for browsers that don't support the API.
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * Basic usage:
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * @customElement("my-component")
 | 
			
		||||
 * class MyComponent extends ViewTransitionMixin(LitElement) {
 | 
			
		||||
 *   render() {
 | 
			
		||||
 *     return html`
 | 
			
		||||
 *       <div class=${classMap({ content: true, loading: !this._loaded })}>
 | 
			
		||||
 *         <slot></slot>
 | 
			
		||||
 *       </div>
 | 
			
		||||
 *     `;
 | 
			
		||||
 *   }
 | 
			
		||||
 *
 | 
			
		||||
 *   static styles = css`
 | 
			
		||||
 *     .content {
 | 
			
		||||
 *       view-transition-name: layout-fade-in;
 | 
			
		||||
 *     }
 | 
			
		||||
 *     .content.loading {
 | 
			
		||||
 *       opacity: 0; // Hidden during initial load for transition
 | 
			
		||||
 *     }
 | 
			
		||||
 *   `;
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * Triggering transitions manually:
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * private _switchView() {
 | 
			
		||||
 *   this.startViewTransition(() => {
 | 
			
		||||
 *     // DOM updates here will be animated
 | 
			
		||||
 *     this.currentView = newView;
 | 
			
		||||
 *   });
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * Custom load behavior:
 | 
			
		||||
 * ```typescript
 | 
			
		||||
 * protected override onLoadTransition(): void {
 | 
			
		||||
 *   // Custom logic before triggering transition
 | 
			
		||||
 *   this.startViewTransition(() => {
 | 
			
		||||
 *     this._loaded = true;
 | 
			
		||||
 *     this._additionalSetup();
 | 
			
		||||
 *   });
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
 *
 | 
			
		||||
 * Features:
 | 
			
		||||
 * - Automatic fade-in transition when slotted content loads
 | 
			
		||||
 * - Provides `_loaded` state property for conditional rendering
 | 
			
		||||
 * - `startViewTransition()` method for manual transitions
 | 
			
		||||
 * - Respects prefers-reduced-motion user preference
 | 
			
		||||
 * - Falls back gracefully when View Transition API unavailable
 | 
			
		||||
 * - Automatic cleanup of event listeners
 | 
			
		||||
 *
 | 
			
		||||
 * The mixin monitors the default slot and triggers `onLoadTransition()` when
 | 
			
		||||
 * content is available. Override `onLoadTransition()` to customize this behavior.
 | 
			
		||||
 */
 | 
			
		||||
export const ViewTransitionMixin = <
 | 
			
		||||
  T extends AbstractConstructor<ReactiveElement>,
 | 
			
		||||
>(
 | 
			
		||||
  superClass: T
 | 
			
		||||
) => {
 | 
			
		||||
  abstract class ViewTransitionClass extends superClass {
 | 
			
		||||
    /**
 | 
			
		||||
     * Reference to the default (unnamed) slot element for monitoring content changes.
 | 
			
		||||
     * Used to detect when slotted content is available to trigger load transitions.
 | 
			
		||||
     */
 | 
			
		||||
    private _slot?: HTMLSlotElement;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prevents multiple slotchange events from triggering the transition more than once.
 | 
			
		||||
     * Once content loads and transition starts, this flag ensures it won't retrigger.
 | 
			
		||||
     */
 | 
			
		||||
    private _transitionTriggered = false;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * State property indicating whether content has finished loading.
 | 
			
		||||
     * Use this in templates with the loading class pattern to hide content until ready.
 | 
			
		||||
     */
 | 
			
		||||
    @state() protected _loaded = false;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Trigger a view transition if supported by the browser
 | 
			
		||||
     * @param updateCallback - Callback function that updates the DOM
 | 
			
		||||
     * @returns Promise that resolves when the transition is complete
 | 
			
		||||
     */
 | 
			
		||||
    protected async startViewTransition(
 | 
			
		||||
      updateCallback: () => void | Promise<void>
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
      if (
 | 
			
		||||
        !document.startViewTransition ||
 | 
			
		||||
        window.matchMedia("(prefers-reduced-motion: reduce)").matches
 | 
			
		||||
      ) {
 | 
			
		||||
        // Fallback: update without view transition
 | 
			
		||||
        await updateCallback();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const transition = document.startViewTransition(async () => {
 | 
			
		||||
        await updateCallback();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await transition.finished;
 | 
			
		||||
      } catch {
 | 
			
		||||
        // View transition failed - this is non-critical, continue silently
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Callback executed when content is ready to transition in.
 | 
			
		||||
     *
 | 
			
		||||
     * Called automatically when:
 | 
			
		||||
     * - The default slot receives content (slotchange event)
 | 
			
		||||
     * - No slot exists in the component (triggers immediately after firstUpdated)
 | 
			
		||||
     *
 | 
			
		||||
     * Default implementation sets `_loaded = true` within a view transition.
 | 
			
		||||
     * Override this method to add custom logic before or during the transition,
 | 
			
		||||
     * but ensure you call `startViewTransition()` to maintain transition behavior.
 | 
			
		||||
     */
 | 
			
		||||
    protected onLoadTransition(): void {
 | 
			
		||||
      this.startViewTransition(() => {
 | 
			
		||||
        this._loaded = true;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if slot has content and trigger transition if it does
 | 
			
		||||
     */
 | 
			
		||||
    private _checkSlotContent = (): void => {
 | 
			
		||||
      // Guard against multiple slotchange events triggering the transition multiple times
 | 
			
		||||
      if (this._transitionTriggered) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (this._slot) {
 | 
			
		||||
        const elements = this._slot.assignedElements();
 | 
			
		||||
        if (elements.length > 0) {
 | 
			
		||||
          this._transitionTriggered = true;
 | 
			
		||||
          this.onLoadTransition();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Automatically apply view transition on first render
 | 
			
		||||
     * @param changedProperties - Properties that changed
 | 
			
		||||
     */
 | 
			
		||||
    protected firstUpdated(changedProperties: PropertyValues): void {
 | 
			
		||||
      super.firstUpdated(changedProperties);
 | 
			
		||||
 | 
			
		||||
      // Wait for slotted content to be ready, then trigger transition
 | 
			
		||||
      // Only monitor the default (unnamed) slot - named slots are for specific purposes
 | 
			
		||||
      this._slot = this.shadowRoot?.querySelector("slot:not([name])") as
 | 
			
		||||
        | HTMLSlotElement
 | 
			
		||||
        | undefined;
 | 
			
		||||
      if (this._slot) {
 | 
			
		||||
        this._checkSlotContent();
 | 
			
		||||
        this._slot.addEventListener("slotchange", this._checkSlotContent);
 | 
			
		||||
      } else {
 | 
			
		||||
        // Start transition immediately if no slot is found
 | 
			
		||||
        this.onLoadTransition();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Cleanup event listeners when component is removed from the DOM.
 | 
			
		||||
     * Removes the slotchange listener.
 | 
			
		||||
     */
 | 
			
		||||
    override disconnectedCallback(): void {
 | 
			
		||||
      super.disconnectedCallback();
 | 
			
		||||
      if (this._slot) {
 | 
			
		||||
        this._slot.removeEventListener("slotchange", this._checkSlotContent);
 | 
			
		||||
        this._slot = undefined;
 | 
			
		||||
        this._transitionTriggered = false;
 | 
			
		||||
        this._loaded = false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return ViewTransitionClass;
 | 
			
		||||
};
 | 
			
		||||
@@ -143,9 +143,14 @@ class DialogCalendarEventDetail extends LitElement {
 | 
			
		||||
      this.hass.locale.time_zone,
 | 
			
		||||
      this.hass.config.time_zone
 | 
			
		||||
    );
 | 
			
		||||
    const start = new TZDate(this._data!.dtstart, timeZone);
 | 
			
		||||
    const endValue = new TZDate(this._data!.dtend, timeZone);
 | 
			
		||||
    // All day events should be displayed as a day earlier
 | 
			
		||||
    // For all-day events (date-only strings), parse without timezone to avoid offset issues
 | 
			
		||||
    const start = isDate(this._data!.dtstart)
 | 
			
		||||
      ? new Date(this._data!.dtstart + "T00:00:00")
 | 
			
		||||
      : new TZDate(this._data!.dtstart, timeZone);
 | 
			
		||||
    const endValue = isDate(this._data!.dtend)
 | 
			
		||||
      ? new Date(this._data!.dtend + "T00:00:00")
 | 
			
		||||
      : new TZDate(this._data!.dtend, timeZone);
 | 
			
		||||
    // All day event end dates are exclusive in iCalendar format, subtract one day for display
 | 
			
		||||
    const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue;
 | 
			
		||||
    // The range can be shortened when the start and end are on the same day.
 | 
			
		||||
    if (isSameDay(start, end)) {
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ import {
 | 
			
		||||
  getFloors,
 | 
			
		||||
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
 | 
			
		||||
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
 | 
			
		||||
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
 | 
			
		||||
 | 
			
		||||
export interface ClimateViewStrategyConfig {
 | 
			
		||||
  type: "climate";
 | 
			
		||||
@@ -152,6 +153,7 @@ export class ClimateViewStrategy extends ReactiveElement {
 | 
			
		||||
              floorCount > 1
 | 
			
		||||
                ? floor.name
 | 
			
		||||
                : hass.localize("ui.panel.lovelace.strategy.home.areas"),
 | 
			
		||||
            icon: floor.icon || floorDefaultIcon(floor),
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,40 +1,39 @@
 | 
			
		||||
import type { HassEntity } from "home-assistant-js-websocket";
 | 
			
		||||
import type { CSSResultGroup } from "lit";
 | 
			
		||||
import { css, html, LitElement, nothing } from "lit";
 | 
			
		||||
import { property, state } from "lit/decorators";
 | 
			
		||||
import type { HassEntity } from "home-assistant-js-websocket";
 | 
			
		||||
import { fireEvent } from "../../../common/dom/fire_event";
 | 
			
		||||
import "../../../components/entity/ha-entity-picker";
 | 
			
		||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
 | 
			
		||||
import "../../../components/ha-alert";
 | 
			
		||||
import "../../../components/ha-aliases-editor";
 | 
			
		||||
import { createCloseHeading } from "../../../components/ha-dialog";
 | 
			
		||||
import "../../../components/ha-floor-picker";
 | 
			
		||||
import "../../../components/ha-icon-picker";
 | 
			
		||||
import "../../../components/ha-labels-picker";
 | 
			
		||||
import "../../../components/ha-picture-upload";
 | 
			
		||||
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
 | 
			
		||||
import "../../../components/ha-settings-row";
 | 
			
		||||
import "../../../components/ha-icon-picker";
 | 
			
		||||
import "../../../components/ha-floor-picker";
 | 
			
		||||
import "../../../components/entity/ha-entity-picker";
 | 
			
		||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
 | 
			
		||||
import "../../../components/ha-textfield";
 | 
			
		||||
import "../../../components/ha-labels-picker";
 | 
			
		||||
import type {
 | 
			
		||||
  AreaRegistryEntry,
 | 
			
		||||
  AreaRegistryEntryMutableParams,
 | 
			
		||||
} from "../../../data/area_registry";
 | 
			
		||||
import { deleteAreaRegistryEntry } from "../../../data/area_registry";
 | 
			
		||||
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
 | 
			
		||||
import { haStyleDialog } from "../../../resources/styles";
 | 
			
		||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
 | 
			
		||||
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
 | 
			
		||||
import {
 | 
			
		||||
  SENSOR_DEVICE_CLASS_HUMIDITY,
 | 
			
		||||
  SENSOR_DEVICE_CLASS_TEMPERATURE,
 | 
			
		||||
} from "../../../data/sensor";
 | 
			
		||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
 | 
			
		||||
import { createCloseHeading } from "../../../components/ha-dialog";
 | 
			
		||||
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
 | 
			
		||||
import { haStyleDialog } from "../../../resources/styles";
 | 
			
		||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
 | 
			
		||||
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
 | 
			
		||||
 | 
			
		||||
const cropOptions: CropOptions = {
 | 
			
		||||
  round: false,
 | 
			
		||||
  type: "image/jpeg",
 | 
			
		||||
  quality: 0.75,
 | 
			
		||||
  aspectRatio: 1.78,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const SENSOR_DOMAINS = ["sensor"];
 | 
			
		||||
@@ -139,6 +138,7 @@ class DialogAreaDetail extends LitElement {
 | 
			
		||||
      ></ha-floor-picker>
 | 
			
		||||
 | 
			
		||||
      <ha-labels-picker
 | 
			
		||||
        .label=${this.hass.localize("ui.components.label-picker.labels")}
 | 
			
		||||
        .hass=${this.hass}
 | 
			
		||||
        .value=${this._labels}
 | 
			
		||||
        @value-changed=${this._labelsChanged}
 | 
			
		||||
@@ -265,19 +265,15 @@ class DialogAreaDetail extends LitElement {
 | 
			
		||||
              ${this.hass.localize("ui.common.delete")}
 | 
			
		||||
            </ha-button>`
 | 
			
		||||
          : nothing}
 | 
			
		||||
        <div slot="primaryAction">
 | 
			
		||||
          <ha-button appearance="plain" @click=${this.closeDialog}>
 | 
			
		||||
            ${this.hass.localize("ui.common.cancel")}
 | 
			
		||||
          </ha-button>
 | 
			
		||||
          <ha-button
 | 
			
		||||
            @click=${this._updateEntry}
 | 
			
		||||
            .disabled=${nameInvalid || !!this._submitting}
 | 
			
		||||
          >
 | 
			
		||||
            ${entry
 | 
			
		||||
              ? this.hass.localize("ui.common.save")
 | 
			
		||||
              : this.hass.localize("ui.common.create")}
 | 
			
		||||
          </ha-button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <ha-button
 | 
			
		||||
          slot="primaryAction"
 | 
			
		||||
          @click=${this._updateEntry}
 | 
			
		||||
          .disabled=${nameInvalid || !!this._submitting}
 | 
			
		||||
        >
 | 
			
		||||
          ${entry
 | 
			
		||||
            ? this.hass.localize("ui.common.save")
 | 
			
		||||
            : this.hass.localize("ui.common.create")}
 | 
			
		||||
        </ha-button>
 | 
			
		||||
      </ha-dialog>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,10 @@
 | 
			
		||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
 | 
			
		||||
import deepClone from "deep-clone-simple";
 | 
			
		||||
import type { PropertyValues } from "lit";
 | 
			
		||||
import { LitElement, html, nothing } from "lit";
 | 
			
		||||
import { html, LitElement, nothing } from "lit";
 | 
			
		||||
import { customElement, property, queryAll, state } from "lit/decorators";
 | 
			
		||||
import { repeat } from "lit/directives/repeat";
 | 
			
		||||
import { ensureArray } from "../../../../common/array/ensure-array";
 | 
			
		||||
import { storage } from "../../../../common/decorators/storage";
 | 
			
		||||
import { fireEvent } from "../../../../common/dom/fire_event";
 | 
			
		||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
 | 
			
		||||
@@ -15,19 +16,18 @@ import {
 | 
			
		||||
  ACTION_BUILDING_BLOCKS,
 | 
			
		||||
  getService,
 | 
			
		||||
  isService,
 | 
			
		||||
  VIRTUAL_ACTIONS,
 | 
			
		||||
} from "../../../../data/action";
 | 
			
		||||
import type { AutomationClipboard } from "../../../../data/automation";
 | 
			
		||||
import type { Action } from "../../../../data/script";
 | 
			
		||||
import type { HomeAssistant } from "../../../../types";
 | 
			
		||||
import {
 | 
			
		||||
  PASTE_VALUE,
 | 
			
		||||
  VIRTUAL_ACTIONS,
 | 
			
		||||
  showAddAutomationElementDialog,
 | 
			
		||||
} from "../show-add-automation-element-dialog";
 | 
			
		||||
import { automationRowsStyles } from "../styles";
 | 
			
		||||
import type HaAutomationActionRow from "./ha-automation-action-row";
 | 
			
		||||
import { getAutomationActionType } from "./ha-automation-action-row";
 | 
			
		||||
import { ensureArray } from "../../../../common/array/ensure-array";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-automation-action")
 | 
			
		||||
export default class HaAutomationAction extends LitElement {
 | 
			
		||||
@@ -136,17 +136,6 @@ export default class HaAutomationAction extends LitElement {
 | 
			
		||||
                "ui.panel.config.automation.editor.actions.add"
 | 
			
		||||
              )}
 | 
			
		||||
            </ha-button>
 | 
			
		||||
            <ha-button
 | 
			
		||||
              .disabled=${this.disabled}
 | 
			
		||||
              @click=${this._addActionBuildingBlockDialog}
 | 
			
		||||
              appearance="plain"
 | 
			
		||||
              .size=${this.root ? "medium" : "small"}
 | 
			
		||||
            >
 | 
			
		||||
              <ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
 | 
			
		||||
              ${this.hass.localize(
 | 
			
		||||
                "ui.panel.config.automation.editor.actions.add_building_block"
 | 
			
		||||
              )}
 | 
			
		||||
            </ha-button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </ha-sortable>
 | 
			
		||||
@@ -222,15 +211,6 @@ export default class HaAutomationAction extends LitElement {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _addActionBuildingBlockDialog() {
 | 
			
		||||
    showAddAutomationElementDialog(this, {
 | 
			
		||||
      type: "action",
 | 
			
		||||
      add: this._addAction,
 | 
			
		||||
      clipboardItem: getAutomationActionType(this._clipboard?.action),
 | 
			
		||||
      group: "building_blocks",
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _addAction = (action: string) => {
 | 
			
		||||
    let actions: Action[];
 | 
			
		||||
    if (action === PASTE_VALUE) {
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -214,17 +214,6 @@ export default class HaAutomationCondition extends LitElement {
 | 
			
		||||
                "ui.panel.config.automation.editor.conditions.add"
 | 
			
		||||
              )}
 | 
			
		||||
            </ha-button>
 | 
			
		||||
            <ha-button
 | 
			
		||||
              .disabled=${this.disabled}
 | 
			
		||||
              appearance="plain"
 | 
			
		||||
              .size=${this.root ? "medium" : "small"}
 | 
			
		||||
              @click=${this._addConditionBuildingBlockDialog}
 | 
			
		||||
            >
 | 
			
		||||
              <ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
 | 
			
		||||
              ${this.hass.localize(
 | 
			
		||||
                "ui.panel.config.automation.editor.conditions.add_building_block"
 | 
			
		||||
              )}
 | 
			
		||||
            </ha-button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </ha-sortable>
 | 
			
		||||
@@ -242,15 +231,6 @@ export default class HaAutomationCondition extends LitElement {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _addConditionBuildingBlockDialog() {
 | 
			
		||||
    showAddAutomationElementDialog(this, {
 | 
			
		||||
      type: "condition",
 | 
			
		||||
      add: this._addCondition,
 | 
			
		||||
      clipboardItem: this._clipboard?.condition?.condition,
 | 
			
		||||
      group: "building_blocks",
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _addCondition = (value) => {
 | 
			
		||||
    let conditions: Condition[];
 | 
			
		||||
    if (value === PASTE_VALUE) {
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,8 @@ export default class HaAutomationSidebar extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "sidebar-key" }) public sidebarKey?: string;
 | 
			
		||||
  @property({ type: Number, attribute: "sidebar-key" })
 | 
			
		||||
  public sidebarKey?: number;
 | 
			
		||||
 | 
			
		||||
  @state() private _yamlMode = false;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -101,7 +101,7 @@ export class HaManualAutomationEditor extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @state() private _sidebarConfig?: SidebarConfig;
 | 
			
		||||
 | 
			
		||||
  @state() private _sidebarKey?: string;
 | 
			
		||||
  @state() private _sidebarKey = 0;
 | 
			
		||||
 | 
			
		||||
  @storage({
 | 
			
		||||
    key: "automation-sidebar-width",
 | 
			
		||||
@@ -350,7 +350,9 @@ export class HaManualAutomationEditor extends LitElement {
 | 
			
		||||
    // deselect previous selected row
 | 
			
		||||
    this._sidebarConfig?.close?.();
 | 
			
		||||
    this._sidebarConfig = ev.detail;
 | 
			
		||||
    this._sidebarKey = JSON.stringify(this._sidebarConfig);
 | 
			
		||||
 | 
			
		||||
    // be sure the sidebar editor is recreated
 | 
			
		||||
    this._sidebarKey++;
 | 
			
		||||
 | 
			
		||||
    await this._sidebarElement?.updateComplete;
 | 
			
		||||
    this._sidebarElement?.focus();
 | 
			
		||||
@@ -375,6 +377,7 @@ export class HaManualAutomationEditor extends LitElement {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this._sidebarConfig?.close();
 | 
			
		||||
      this._sidebarKey = 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,45 +1,11 @@
 | 
			
		||||
import { fireEvent } from "../../../common/dom/fire_event";
 | 
			
		||||
import type { ACTION_GROUPS } from "../../../data/action";
 | 
			
		||||
import type { ActionType } from "../../../data/script";
 | 
			
		||||
 | 
			
		||||
export const PASTE_VALUE = "__paste__";
 | 
			
		||||
 | 
			
		||||
// These will be replaced with the correct action
 | 
			
		||||
export const VIRTUAL_ACTIONS: Record<
 | 
			
		||||
  keyof (typeof ACTION_GROUPS)["building_blocks"]["members"],
 | 
			
		||||
  ActionType
 | 
			
		||||
> = {
 | 
			
		||||
  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;
 | 
			
		||||
 | 
			
		||||
export interface AddAutomationElementDialogParams {
 | 
			
		||||
  type: "trigger" | "condition" | "action";
 | 
			
		||||
  add: (key: string) => void;
 | 
			
		||||
  clipboardItem: string | undefined;
 | 
			
		||||
  group?: string;
 | 
			
		||||
}
 | 
			
		||||
const loadDialog = () => import("./add-automation-element-dialog");
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { consume } from "@lit/context";
 | 
			
		||||
import {
 | 
			
		||||
  mdiAppleKeyboardCommand,
 | 
			
		||||
  mdiContentCopy,
 | 
			
		||||
@@ -13,6 +14,7 @@ import {
 | 
			
		||||
import { html, LitElement, nothing } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators";
 | 
			
		||||
import { keyed } from "lit/directives/keyed";
 | 
			
		||||
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
 | 
			
		||||
import { fireEvent } from "../../../../common/dom/fire_event";
 | 
			
		||||
import { handleStructError } from "../../../../common/structs/handle-errors";
 | 
			
		||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
 | 
			
		||||
@@ -20,7 +22,16 @@ import "../../../../components/ha-md-divider";
 | 
			
		||||
import "../../../../components/ha-md-menu-item";
 | 
			
		||||
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
 | 
			
		||||
import type { ActionSidebarConfig } from "../../../../data/automation";
 | 
			
		||||
import {
 | 
			
		||||
  floorsContext,
 | 
			
		||||
  fullEntitiesContext,
 | 
			
		||||
  labelsContext,
 | 
			
		||||
} from "../../../../data/context";
 | 
			
		||||
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
 | 
			
		||||
import type { FloorRegistryEntry } from "../../../../data/floor_registry";
 | 
			
		||||
import type { LabelRegistryEntry } from "../../../../data/label_registry";
 | 
			
		||||
import type { RepeatAction } from "../../../../data/script";
 | 
			
		||||
import { describeAction } from "../../../../data/script_i18n";
 | 
			
		||||
import type { HomeAssistant } from "../../../../types";
 | 
			
		||||
import { isMac } from "../../../../util/is_mac";
 | 
			
		||||
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
 | 
			
		||||
@@ -44,10 +55,23 @@ export default class HaAutomationSidebarAction extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "sidebar-key" }) public sidebarKey?: string;
 | 
			
		||||
  @property({ type: Number, attribute: "sidebar-key" })
 | 
			
		||||
  public sidebarKey?: number;
 | 
			
		||||
 | 
			
		||||
  @state() private _warnings?: string[];
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  @consume({ context: fullEntitiesContext, subscribe: true })
 | 
			
		||||
  _entityReg!: EntityRegistryEntry[];
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  @consume({ context: labelsContext, subscribe: true })
 | 
			
		||||
  _labelReg!: LabelRegistryEntry[];
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  @consume({ context: floorsContext, subscribe: true })
 | 
			
		||||
  _floorReg!: Record<string, FloorRegistryEntry>;
 | 
			
		||||
 | 
			
		||||
  @query(".sidebar-editor")
 | 
			
		||||
  public editor?: HaAutomationConditionEditor;
 | 
			
		||||
 | 
			
		||||
@@ -78,15 +102,20 @@ export default class HaAutomationSidebarAction extends LitElement {
 | 
			
		||||
 | 
			
		||||
    const isBuildingBlock = ACTION_BUILDING_BLOCKS.includes(type || "");
 | 
			
		||||
 | 
			
		||||
    const title = capitalizeFirstLetter(
 | 
			
		||||
      describeAction(
 | 
			
		||||
        this.hass,
 | 
			
		||||
        this._entityReg,
 | 
			
		||||
        this._labelReg,
 | 
			
		||||
        this._floorReg,
 | 
			
		||||
        actionConfig
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const subtitle = this.hass.localize(
 | 
			
		||||
      "ui.panel.config.automation.editor.actions.action"
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const title =
 | 
			
		||||
      this.hass.localize(
 | 
			
		||||
        `ui.panel.config.automation.editor.actions.type.${type}.label` as LocalizeKeys
 | 
			
		||||
      ) || type;
 | 
			
		||||
 | 
			
		||||
    const description = isBuildingBlock
 | 
			
		||||
      ? this.hass.localize(
 | 
			
		||||
          `ui.panel.config.automation.editor.actions.type.${type}.description.picker` as LocalizeKeys
 | 
			
		||||
 
 | 
			
		||||
@@ -44,7 +44,8 @@ export default class HaAutomationSidebarCondition extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "sidebar-key" }) public sidebarKey?: string;
 | 
			
		||||
  @property({ type: Number, attribute: "sidebar-key" })
 | 
			
		||||
  public sidebarKey?: number;
 | 
			
		||||
 | 
			
		||||
  @state() private _warnings?: string[];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,8 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "sidebar-key" }) public sidebarKey?: string;
 | 
			
		||||
  @property({ type: Number, attribute: "sidebar-key" })
 | 
			
		||||
  public sidebarKey?: number;
 | 
			
		||||
 | 
			
		||||
  @state() private _warnings?: string[];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,8 @@ export default class HaAutomationSidebarScriptField extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "sidebar-key" }) public sidebarKey?: string;
 | 
			
		||||
  @property({ type: Number, attribute: "sidebar-key" })
 | 
			
		||||
  public sidebarKey?: number;
 | 
			
		||||
 | 
			
		||||
  @state() private _warnings?: string[];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import {
 | 
			
		||||
  mdiContentCopy,
 | 
			
		||||
  mdiContentCut,
 | 
			
		||||
  mdiDelete,
 | 
			
		||||
  mdiIdentifier,
 | 
			
		||||
  mdiPlayCircleOutline,
 | 
			
		||||
  mdiPlaylistEdit,
 | 
			
		||||
  mdiPlusCircleMultipleOutline,
 | 
			
		||||
@@ -37,7 +38,10 @@ export default class HaAutomationSidebarTrigger extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean }) public narrow = false;
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: "sidebar-key" }) public sidebarKey?: string;
 | 
			
		||||
  @property({ type: Number, attribute: "sidebar-key" })
 | 
			
		||||
  public sidebarKey?: number;
 | 
			
		||||
 | 
			
		||||
  @state() private _requestShowId = false;
 | 
			
		||||
 | 
			
		||||
  @state() private _warnings?: string[];
 | 
			
		||||
 | 
			
		||||
@@ -46,6 +50,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
 | 
			
		||||
 | 
			
		||||
  protected willUpdate(changedProperties) {
 | 
			
		||||
    if (changedProperties.has("config")) {
 | 
			
		||||
      this._requestShowId = false;
 | 
			
		||||
      this._warnings = undefined;
 | 
			
		||||
      if (this.config) {
 | 
			
		||||
        this.yamlMode = this.config.yamlMode;
 | 
			
		||||
@@ -100,6 +105,24 @@ export default class HaAutomationSidebarTrigger extends LitElement {
 | 
			
		||||
          </div>
 | 
			
		||||
        </ha-md-menu-item>
 | 
			
		||||
 | 
			
		||||
        ${!this.yamlMode &&
 | 
			
		||||
        !("id" in this.config.config) &&
 | 
			
		||||
        !this._requestShowId
 | 
			
		||||
          ? html`<ha-md-menu-item
 | 
			
		||||
              slot="menu-items"
 | 
			
		||||
              .clickAction=${this._showTriggerId}
 | 
			
		||||
              .disabled=${this.disabled || type === "list"}
 | 
			
		||||
            >
 | 
			
		||||
              <ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
 | 
			
		||||
              <div class="overflow-label">
 | 
			
		||||
                ${this.hass.localize(
 | 
			
		||||
                  "ui.panel.config.automation.editor.triggers.edit_id"
 | 
			
		||||
                )}
 | 
			
		||||
                <span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </ha-md-menu-item>`
 | 
			
		||||
          : nothing}
 | 
			
		||||
 | 
			
		||||
        <ha-md-divider
 | 
			
		||||
          slot="menu-items"
 | 
			
		||||
          role="separator"
 | 
			
		||||
@@ -249,6 +272,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
 | 
			
		||||
            @value-changed=${this._valueChangedSidebar}
 | 
			
		||||
            @yaml-changed=${this._yamlChangedSidebar}
 | 
			
		||||
            .uiSupported=${this.config.uiSupported}
 | 
			
		||||
            .showId=${this._requestShowId}
 | 
			
		||||
            .yamlMode=${this.yamlMode}
 | 
			
		||||
            .disabled=${this.disabled}
 | 
			
		||||
            @ui-mode-not-available=${this._handleUiModeNotAvailable}
 | 
			
		||||
@@ -291,6 +315,10 @@ export default class HaAutomationSidebarTrigger extends LitElement {
 | 
			
		||||
    fireEvent(this, "toggle-yaml-mode");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private _showTriggerId = () => {
 | 
			
		||||
    this._requestShowId = true;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static styles = [sidebarEditorStyles, overflowStyles];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Boolean, attribute: "show-id" }) public showId = false;
 | 
			
		||||
 | 
			
		||||
  @query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
@@ -36,6 +38,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
 | 
			
		||||
 | 
			
		||||
    const yamlMode = this.yamlMode || !this.uiSupported;
 | 
			
		||||
 | 
			
		||||
    const showId = "id" in this.trigger || this.showId;
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <div
 | 
			
		||||
        class=${classMap({
 | 
			
		||||
@@ -70,20 +74,15 @@ export default class HaAutomationTriggerEditor extends LitElement {
 | 
			
		||||
              ></ha-yaml-editor>
 | 
			
		||||
            `
 | 
			
		||||
          : html`
 | 
			
		||||
              ${!isTriggerList(this.trigger)
 | 
			
		||||
              ${showId && !isTriggerList(this.trigger)
 | 
			
		||||
                ? html`
 | 
			
		||||
                    <ha-textfield
 | 
			
		||||
                      .label=${`${this.hass.localize(
 | 
			
		||||
                      .label=${this.hass.localize(
 | 
			
		||||
                        "ui.panel.config.automation.editor.triggers.id"
 | 
			
		||||
                      )} (${this.hass.localize(
 | 
			
		||||
                        "ui.panel.config.automation.editor.triggers.optional"
 | 
			
		||||
                      )})`}
 | 
			
		||||
                      )}
 | 
			
		||||
                      .value=${this.trigger.id || ""}
 | 
			
		||||
                      .disabled=${this.disabled}
 | 
			
		||||
                      @change=${this._idChanged}
 | 
			
		||||
                      .helper=${this.hass.localize(
 | 
			
		||||
                        "ui.panel.config.automation.editor.triggers.id_helper"
 | 
			
		||||
                      )}
 | 
			
		||||
                    ></ha-textfield>
 | 
			
		||||
                  `
 | 
			
		||||
                : nothing}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,7 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
 | 
			
		||||
import { fireEvent } from "../../../../../common/dom/fire_event";
 | 
			
		||||
import { hasTemplate } from "../../../../../common/string/has-template";
 | 
			
		||||
import type { StateTrigger } from "../../../../../data/automation";
 | 
			
		||||
import { ANY_STATE_VALUE } from "../../../../../components/entity/const";
 | 
			
		||||
import type { HomeAssistant } from "../../../../../types";
 | 
			
		||||
import { baseTriggerStruct, forDictStruct } from "../../structs";
 | 
			
		||||
import type { TriggerElement } from "../ha-automation-trigger-row";
 | 
			
		||||
@@ -36,14 +37,12 @@ const stateTriggerStruct = assign(
 | 
			
		||||
    trigger: literal("state"),
 | 
			
		||||
    entity_id: optional(union([string(), array(string())])),
 | 
			
		||||
    attribute: optional(string()),
 | 
			
		||||
    from: optional(nullable(string())),
 | 
			
		||||
    to: optional(nullable(string())),
 | 
			
		||||
    from: optional(union([nullable(string()), array(string())])),
 | 
			
		||||
    to: optional(union([nullable(string()), array(string())])),
 | 
			
		||||
    for: optional(union([number(), string(), forDictStruct])),
 | 
			
		||||
  })
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-automation-trigger-state")
 | 
			
		||||
export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
  @property({ attribute: false }) public hass!: HomeAssistant;
 | 
			
		||||
@@ -57,7 +56,12 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _schema = memoizeOne(
 | 
			
		||||
    (localize: LocalizeFunc, attribute) =>
 | 
			
		||||
    (
 | 
			
		||||
      localize: LocalizeFunc,
 | 
			
		||||
      attribute: string | undefined,
 | 
			
		||||
      hideInFrom: string[],
 | 
			
		||||
      hideInTo: string[]
 | 
			
		||||
    ) =>
 | 
			
		||||
      [
 | 
			
		||||
        {
 | 
			
		||||
          name: "entity_id",
 | 
			
		||||
@@ -131,6 +135,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
          },
 | 
			
		||||
          selector: {
 | 
			
		||||
            state: {
 | 
			
		||||
              multiple: true,
 | 
			
		||||
              extra_options: (attribute
 | 
			
		||||
                ? []
 | 
			
		||||
                : [
 | 
			
		||||
@@ -142,6 +147,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
                    },
 | 
			
		||||
                  ]) as any,
 | 
			
		||||
              attribute: attribute,
 | 
			
		||||
              hide_states: hideInFrom,
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
@@ -152,6 +158,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
          },
 | 
			
		||||
          selector: {
 | 
			
		||||
            state: {
 | 
			
		||||
              multiple: true,
 | 
			
		||||
              extra_options: (attribute
 | 
			
		||||
                ? []
 | 
			
		||||
                : [
 | 
			
		||||
@@ -163,6 +170,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
                    },
 | 
			
		||||
                  ]) as any,
 | 
			
		||||
              attribute: attribute,
 | 
			
		||||
              hide_states: hideInTo,
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
@@ -207,13 +215,15 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
      entity_id: ensureArray(this.trigger.entity_id),
 | 
			
		||||
      for: trgFor,
 | 
			
		||||
    };
 | 
			
		||||
    if (!data.attribute && data.to === null) {
 | 
			
		||||
      data.to = ANY_STATE_VALUE;
 | 
			
		||||
    }
 | 
			
		||||
    if (!data.attribute && data.from === null) {
 | 
			
		||||
      data.from = ANY_STATE_VALUE;
 | 
			
		||||
    }
 | 
			
		||||
    const schema = this._schema(this.hass.localize, this.trigger.attribute);
 | 
			
		||||
 | 
			
		||||
    data.to = this._normalizeStates(this.trigger.to, data.attribute);
 | 
			
		||||
    data.from = this._normalizeStates(this.trigger.from, data.attribute);
 | 
			
		||||
    const schema = this._schema(
 | 
			
		||||
      this.hass.localize,
 | 
			
		||||
      this.trigger.attribute,
 | 
			
		||||
      data.to,
 | 
			
		||||
      data.from
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return html`
 | 
			
		||||
      <ha-form
 | 
			
		||||
@@ -231,22 +241,60 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
 | 
			
		||||
    ev.stopPropagation();
 | 
			
		||||
    const newTrigger = ev.detail.value;
 | 
			
		||||
 | 
			
		||||
    if (newTrigger.to === ANY_STATE_VALUE) {
 | 
			
		||||
      newTrigger.to = newTrigger.attribute ? undefined : null;
 | 
			
		||||
    newTrigger.to = this._applyAnyStateExclusive(
 | 
			
		||||
      newTrigger.to,
 | 
			
		||||
      newTrigger.attribute
 | 
			
		||||
    );
 | 
			
		||||
    if (Array.isArray(newTrigger.to) && newTrigger.to.length === 0) {
 | 
			
		||||
      delete newTrigger.to;
 | 
			
		||||
    }
 | 
			
		||||
    if (newTrigger.from === ANY_STATE_VALUE) {
 | 
			
		||||
      newTrigger.from = newTrigger.attribute ? undefined : null;
 | 
			
		||||
    newTrigger.from = this._applyAnyStateExclusive(
 | 
			
		||||
      newTrigger.from,
 | 
			
		||||
      newTrigger.attribute
 | 
			
		||||
    );
 | 
			
		||||
    if (Array.isArray(newTrigger.from) && newTrigger.from.length === 0) {
 | 
			
		||||
      delete newTrigger.from;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Object.keys(newTrigger).forEach((key) =>
 | 
			
		||||
      newTrigger[key] === undefined || newTrigger[key] === ""
 | 
			
		||||
        ? delete newTrigger[key]
 | 
			
		||||
        : {}
 | 
			
		||||
    );
 | 
			
		||||
    Object.keys(newTrigger).forEach((key) => {
 | 
			
		||||
      const val = newTrigger[key];
 | 
			
		||||
      if (val === undefined || val === "") {
 | 
			
		||||
        delete newTrigger[key];
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    fireEvent(this, "value-changed", { value: newTrigger });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _applyAnyStateExclusive(
 | 
			
		||||
    val: string | string[] | null | undefined,
 | 
			
		||||
    attribute?: string
 | 
			
		||||
  ): string | string[] | null | undefined {
 | 
			
		||||
    const anyStateSelected = Array.isArray(val)
 | 
			
		||||
      ? val.includes(ANY_STATE_VALUE)
 | 
			
		||||
      : val === ANY_STATE_VALUE;
 | 
			
		||||
    if (anyStateSelected) {
 | 
			
		||||
      // Any state is exclusive: null if no attribute, undefined if attribute
 | 
			
		||||
      return attribute ? undefined : null;
 | 
			
		||||
    }
 | 
			
		||||
    return val;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _normalizeStates(
 | 
			
		||||
    value: string | string[] | null | undefined,
 | 
			
		||||
    attribute?: string
 | 
			
		||||
  ): string[] {
 | 
			
		||||
    // If no attribute is selected and backend value is null,
 | 
			
		||||
    // expose it as the special ANY state option in the UI.
 | 
			
		||||
    if (!attribute && value === null) {
 | 
			
		||||
      return [ANY_STATE_VALUE];
 | 
			
		||||
    }
 | 
			
		||||
    if (value === undefined || value === null) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
    return ensureArray(value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _computeLabelCallback = (
 | 
			
		||||
    schema: SchemaUnion<ReturnType<typeof this._schema>>
 | 
			
		||||
  ): string =>
 | 
			
		||||
 
 | 
			
		||||
@@ -98,7 +98,7 @@ class DialogCategoryDetail extends LitElement {
 | 
			
		||||
        </div>
 | 
			
		||||
        <ha-button
 | 
			
		||||
          appearance="plain"
 | 
			
		||||
          slot="primaryAction"
 | 
			
		||||
          slot="secondaryAction"
 | 
			
		||||
          @click=${this.closeDialog}
 | 
			
		||||
        >
 | 
			
		||||
          ${this.hass.localize("ui.common.cancel")}
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ import { subscribeEntityRegistry } from "../../../data/entity_registry";
 | 
			
		||||
import type { UpdateEntity } from "../../../data/update";
 | 
			
		||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
 | 
			
		||||
import type { HomeAssistant } from "../../../types";
 | 
			
		||||
import "../../../components/ha-progress-ring";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-config-updates")
 | 
			
		||||
class HaConfigUpdates extends SubscribeMixin(LitElement) {
 | 
			
		||||
@@ -56,6 +57,29 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
 | 
			
		||||
      this._entities?.find((entity) => entity.entity_id === entityId)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  private _renderUpdateProgress(entity: UpdateEntity) {
 | 
			
		||||
    if (entity.attributes.update_percentage != null) {
 | 
			
		||||
      return html`<ha-progress-ring
 | 
			
		||||
        size="small"
 | 
			
		||||
        .value=${entity.attributes.update_percentage}
 | 
			
		||||
        .label=${this.hass.localize(
 | 
			
		||||
          "ui.panel.config.updates.update_in_progress"
 | 
			
		||||
        )}
 | 
			
		||||
      ></ha-progress-ring>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (entity.attributes.in_progress) {
 | 
			
		||||
      return html`<ha-spinner
 | 
			
		||||
        size="small"
 | 
			
		||||
        .ariaLabel=${this.hass.localize(
 | 
			
		||||
          "ui.panel.config.updates.update_in_progress"
 | 
			
		||||
        )}
 | 
			
		||||
      ></ha-spinner>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return html`<ha-icon-next></ha-icon-next>`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected render() {
 | 
			
		||||
    if (!this.updateEntities?.length) {
 | 
			
		||||
      return nothing;
 | 
			
		||||
@@ -106,13 +130,9 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
 | 
			
		||||
                  )}
 | 
			
		||||
                ></state-badge>
 | 
			
		||||
                ${this.narrow && entity.attributes.in_progress
 | 
			
		||||
                  ? html`<ha-spinner
 | 
			
		||||
                      class="absolute"
 | 
			
		||||
                      size="small"
 | 
			
		||||
                      .ariaLabel=${this.hass.localize(
 | 
			
		||||
                        "ui.panel.config.updates.update_in_progress"
 | 
			
		||||
                      )}
 | 
			
		||||
                    ></ha-spinner>`
 | 
			
		||||
                  ? html`<div class="absolute">
 | 
			
		||||
                      ${this._renderUpdateProgress(entity)}
 | 
			
		||||
                    </div>`
 | 
			
		||||
                  : nothing}
 | 
			
		||||
              </div>
 | 
			
		||||
              <span slot="headline"
 | 
			
		||||
@@ -128,16 +148,9 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
 | 
			
		||||
                  : nothing}
 | 
			
		||||
              </span>
 | 
			
		||||
              ${!this.narrow
 | 
			
		||||
                ? entity.attributes.in_progress
 | 
			
		||||
                  ? html`<div slot="end">
 | 
			
		||||
                      <ha-spinner
 | 
			
		||||
                        size="small"
 | 
			
		||||
                        .ariaLabel=${this.hass.localize(
 | 
			
		||||
                          "ui.panel.config.updates.update_in_progress"
 | 
			
		||||
                        )}
 | 
			
		||||
                      ></ha-spinner>
 | 
			
		||||
                    </div>`
 | 
			
		||||
                  : html`<ha-icon-next slot="end"></ha-icon-next>`
 | 
			
		||||
                ? html`<div slot="end">
 | 
			
		||||
                    ${this._renderUpdateProgress(entity)}
 | 
			
		||||
                  </div>`
 | 
			
		||||
                : nothing}
 | 
			
		||||
            </ha-md-list-item>
 | 
			
		||||
          `;
 | 
			
		||||
@@ -193,13 +206,13 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
 | 
			
		||||
        div[slot="start"] {
 | 
			
		||||
          position: relative;
 | 
			
		||||
        }
 | 
			
		||||
        ha-spinner.absolute {
 | 
			
		||||
        div.absolute {
 | 
			
		||||
          position: absolute;
 | 
			
		||||
          left: 6px;
 | 
			
		||||
          top: 6px;
 | 
			
		||||
        }
 | 
			
		||||
        state-badge.updating {
 | 
			
		||||
          opacity: 0.5;
 | 
			
		||||
          opacity: 0.2;
 | 
			
		||||
        }
 | 
			
		||||
      `,
 | 
			
		||||
    ];
 | 
			
		||||
 
 | 
			
		||||
@@ -228,7 +228,7 @@ export class HaDeviceEntitiesCard extends LitElement {
 | 
			
		||||
    addEntitiesToLovelaceView(
 | 
			
		||||
      this,
 | 
			
		||||
      this.hass,
 | 
			
		||||
      computeCards(this.hass.states, entities, {
 | 
			
		||||
      computeCards(this.hass, entities, {
 | 
			
		||||
        title: this.deviceName,
 | 
			
		||||
      }),
 | 
			
		||||
      computeSection(entities, {
 | 
			
		||||
 
 | 
			
		||||
@@ -173,7 +173,7 @@ export class DialogEnergySolarSettings
 | 
			
		||||
                    <ha-checkbox
 | 
			
		||||
                      .entry=${entry}
 | 
			
		||||
                      @change=${this._forecastCheckChanged}
 | 
			
		||||
                      .checked=${this._source?.config_entry_solar_forecast?.includes(
 | 
			
		||||
                      .checked=${!!this._source?.config_entry_solar_forecast?.includes(
 | 
			
		||||
                        entry.entry_id
 | 
			
		||||
                      )}
 | 
			
		||||
                    >
 | 
			
		||||
 
 | 
			
		||||
@@ -492,7 +492,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
 | 
			
		||||
                        )}</span
 | 
			
		||||
                      >
 | 
			
		||||
                      <ha-switch
 | 
			
		||||
                        .checked=${this.entry.options?.switch_as_x?.invert}
 | 
			
		||||
                        .checked=${!!this.entry.options?.switch_as_x?.invert}
 | 
			
		||||
                        @change=${this._switchAsInvertChanged}
 | 
			
		||||
                      ></ha-switch>
 | 
			
		||||
                    </ha-settings-row>
 | 
			
		||||
 
 | 
			
		||||
@@ -126,7 +126,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
 | 
			
		||||
          .hass=${this.hass}
 | 
			
		||||
          .entry=${this.entry}
 | 
			
		||||
          .helperConfigEntry=${this._helperConfigEntry}
 | 
			
		||||
          .disabled=${this._submitting}
 | 
			
		||||
          .disabled=${!!this._submitting}
 | 
			
		||||
          @change=${this._entityRegistryChanged}
 | 
			
		||||
        ></entity-registry-settings-editor>
 | 
			
		||||
      </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -153,6 +153,8 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
 | 
			
		||||
        },
 | 
			
		||||
        yAxis: {
 | 
			
		||||
          type: "value",
 | 
			
		||||
          min: 0,
 | 
			
		||||
          max: 100,
 | 
			
		||||
          splitLine: {
 | 
			
		||||
            show: true,
 | 
			
		||||
          },
 | 
			
		||||
 
 | 
			
		||||
@@ -191,7 +191,7 @@ class ZWaveJSCapabilityDoorLock extends LitElement {
 | 
			
		||||
                <ha-switch
 | 
			
		||||
                  @change=${this._booleanChanged}
 | 
			
		||||
                  key="twistAssist"
 | 
			
		||||
                  .checked=${this._configuration?.twistAssist}
 | 
			
		||||
                  .checked=${!!this._configuration?.twistAssist}
 | 
			
		||||
                >
 | 
			
		||||
                </ha-switch>
 | 
			
		||||
              </ha-formfield>
 | 
			
		||||
@@ -209,7 +209,7 @@ class ZWaveJSCapabilityDoorLock extends LitElement {
 | 
			
		||||
                <ha-switch
 | 
			
		||||
                  @change=${this._booleanChanged}
 | 
			
		||||
                  key="blockToBlock"
 | 
			
		||||
                  .checked=${this._configuration?.blockToBlock}
 | 
			
		||||
                  .checked=${!!this._configuration?.blockToBlock}
 | 
			
		||||
                >
 | 
			
		||||
                </ha-switch>
 | 
			
		||||
              </ha-formfield>
 | 
			
		||||
 
 | 
			
		||||
@@ -82,7 +82,7 @@ class DialogLabelDetail
 | 
			
		||||
          this.hass,
 | 
			
		||||
          this._params.entry
 | 
			
		||||
            ? this._params.entry.name || this._params.entry.label_id
 | 
			
		||||
            : this.hass!.localize("ui.panel.config.labels.detail.new_label")
 | 
			
		||||
            : this.hass!.localize("ui.dialogs.label-detail.new_label")
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        <div>
 | 
			
		||||
@@ -95,11 +95,9 @@ class DialogLabelDetail
 | 
			
		||||
              .value=${this._name}
 | 
			
		||||
              .configValue=${"name"}
 | 
			
		||||
              @input=${this._input}
 | 
			
		||||
              .label=${this.hass!.localize(
 | 
			
		||||
                "ui.panel.config.labels.detail.name"
 | 
			
		||||
              )}
 | 
			
		||||
              .label=${this.hass!.localize("ui.dialogs.label-detail.name")}
 | 
			
		||||
              .validationMessage=${this.hass!.localize(
 | 
			
		||||
                "ui.panel.config.labels.detail.required_error_msg"
 | 
			
		||||
                "ui.dialogs.label-detail.required_error_msg"
 | 
			
		||||
              )}
 | 
			
		||||
              required
 | 
			
		||||
            ></ha-textfield>
 | 
			
		||||
@@ -108,25 +106,21 @@ class DialogLabelDetail
 | 
			
		||||
              .hass=${this.hass}
 | 
			
		||||
              .configValue=${"icon"}
 | 
			
		||||
              @value-changed=${this._valueChanged}
 | 
			
		||||
              .label=${this.hass!.localize(
 | 
			
		||||
                "ui.panel.config.labels.detail.icon"
 | 
			
		||||
              )}
 | 
			
		||||
              .label=${this.hass!.localize("ui.dialogs.label-detail.icon")}
 | 
			
		||||
            ></ha-icon-picker>
 | 
			
		||||
            <ha-color-picker
 | 
			
		||||
              .value=${this._color}
 | 
			
		||||
              .configValue=${"color"}
 | 
			
		||||
              .hass=${this.hass}
 | 
			
		||||
              @value-changed=${this._valueChanged}
 | 
			
		||||
              .label=${this.hass!.localize(
 | 
			
		||||
                "ui.panel.config.labels.detail.color"
 | 
			
		||||
              )}
 | 
			
		||||
              .label=${this.hass!.localize("ui.dialogs.label-detail.color")}
 | 
			
		||||
            ></ha-color-picker>
 | 
			
		||||
            <ha-textarea
 | 
			
		||||
              .value=${this._description}
 | 
			
		||||
              .configValue=${"description"}
 | 
			
		||||
              @input=${this._input}
 | 
			
		||||
              .label=${this.hass!.localize(
 | 
			
		||||
                "ui.panel.config.labels.detail.description"
 | 
			
		||||
                "ui.dialogs.label-detail.description"
 | 
			
		||||
              )}
 | 
			
		||||
            ></ha-textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -140,7 +134,7 @@ class DialogLabelDetail
 | 
			
		||||
                @click=${this._deleteEntry}
 | 
			
		||||
                .disabled=${this._submitting}
 | 
			
		||||
              >
 | 
			
		||||
                ${this.hass!.localize("ui.panel.config.labels.detail.delete")}
 | 
			
		||||
                ${this.hass!.localize("ui.common.delete")}
 | 
			
		||||
              </ha-button>
 | 
			
		||||
            `
 | 
			
		||||
          : nothing}
 | 
			
		||||
@@ -150,8 +144,8 @@ class DialogLabelDetail
 | 
			
		||||
          .disabled=${this._submitting || !this._name}
 | 
			
		||||
        >
 | 
			
		||||
          ${this._params.entry
 | 
			
		||||
            ? this.hass!.localize("ui.panel.config.labels.detail.update")
 | 
			
		||||
            : this.hass!.localize("ui.panel.config.labels.detail.create")}
 | 
			
		||||
            ? this.hass!.localize("ui.common.update")
 | 
			
		||||
            : this.hass!.localize("ui.common.create")}
 | 
			
		||||
        </ha-button>
 | 
			
		||||
      </ha-dialog>
 | 
			
		||||
    `;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import {
 | 
			
		||||
  mdiDevices,
 | 
			
		||||
  mdiDotsVertical,
 | 
			
		||||
  mdiHelpCircle,
 | 
			
		||||
  mdiLabelOutline,
 | 
			
		||||
  mdiPlus,
 | 
			
		||||
  mdiRobot,
 | 
			
		||||
  mdiShape,
 | 
			
		||||
@@ -23,8 +24,10 @@ import type {
 | 
			
		||||
  SortingChangedEvent,
 | 
			
		||||
} from "../../../components/data-table/ha-data-table";
 | 
			
		||||
import "../../../components/ha-fab";
 | 
			
		||||
import "../../../components/ha-icon";
 | 
			
		||||
import "../../../components/ha-icon-button";
 | 
			
		||||
import "../../../components/ha-relative-time";
 | 
			
		||||
import type { HaMdMenu } from "../../../components/ha-md-menu";
 | 
			
		||||
import "../../../components/ha-svg-icon";
 | 
			
		||||
import type {
 | 
			
		||||
  LabelRegistryEntry,
 | 
			
		||||
  LabelRegistryEntryMutableParams,
 | 
			
		||||
@@ -43,7 +46,6 @@ import "../../../layouts/hass-tabs-subpage-data-table";
 | 
			
		||||
import type { HomeAssistant, Route } from "../../../types";
 | 
			
		||||
import { configSections } from "../ha-panel-config";
 | 
			
		||||
import { showLabelDetailDialog } from "./show-dialog-label-detail";
 | 
			
		||||
import type { HaMdMenu } from "../../../components/ha-md-menu";
 | 
			
		||||
 | 
			
		||||
@customElement("ha-config-labels")
 | 
			
		||||
export class HaConfigLabels extends LitElement {
 | 
			
		||||
@@ -100,7 +102,9 @@ export class HaConfigLabels extends LitElement {
 | 
			
		||||
        label: localize("ui.panel.config.labels.headers.icon"),
 | 
			
		||||
        type: "icon",
 | 
			
		||||
        template: (label) =>
 | 
			
		||||
          label.icon ? html`<ha-icon .icon=${label.icon}></ha-icon>` : nothing,
 | 
			
		||||
          label.icon
 | 
			
		||||
            ? html`<ha-icon .icon=${label.icon}></ha-icon>`
 | 
			
		||||
            : html`<ha-svg-icon .path=${mdiLabelOutline}></ha-svg-icon>`,
 | 
			
		||||
      },
 | 
			
		||||
      color: {
 | 
			
		||||
        title: "",
 | 
			
		||||
@@ -108,18 +112,18 @@ export class HaConfigLabels extends LitElement {
 | 
			
		||||
        label: localize("ui.panel.config.labels.headers.color"),
 | 
			
		||||
        type: "icon",
 | 
			
		||||
        template: (label) =>
 | 
			
		||||
          label.color
 | 
			
		||||
            ? html`<div
 | 
			
		||||
                style=${styleMap({
 | 
			
		||||
                  backgroundColor: computeCssColor(label.color),
 | 
			
		||||
                  borderRadius: "var(--ha-border-radius-md)",
 | 
			
		||||
                  border: "1px solid var(--outline-color)",
 | 
			
		||||
                  boxSizing: "border-box",
 | 
			
		||||
                  width: "20px",
 | 
			
		||||
                  height: "20px",
 | 
			
		||||
                })}
 | 
			
		||||
              ></div>`
 | 
			
		||||
            : nothing,
 | 
			
		||||
          html`<div
 | 
			
		||||
            style=${styleMap({
 | 
			
		||||
              backgroundColor: label.color
 | 
			
		||||
                ? computeCssColor(label.color)
 | 
			
		||||
                : undefined,
 | 
			
		||||
              borderRadius: "var(--ha-border-radius-md)",
 | 
			
		||||
              border: "1px solid var(--outline-color)",
 | 
			
		||||
              boxSizing: "border-box",
 | 
			
		||||
              width: "var(--ha-space-5)",
 | 
			
		||||
              height: "var(--ha-space-5)",
 | 
			
		||||
            })}
 | 
			
		||||
          ></div>`,
 | 
			
		||||
      },
 | 
			
		||||
      name: {
 | 
			
		||||
        title: localize("ui.panel.config.labels.headers.name"),
 | 
			
		||||
 
 | 
			
		||||
@@ -89,7 +89,7 @@ export class HaManualScriptEditor extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @state() private _sidebarConfig?: SidebarConfig;
 | 
			
		||||
 | 
			
		||||
  @state() private _sidebarKey?: string;
 | 
			
		||||
  @state() private _sidebarKey = 0;
 | 
			
		||||
 | 
			
		||||
  @storage({
 | 
			
		||||
    key: "automation-sidebar-width",
 | 
			
		||||
@@ -512,7 +512,9 @@ export class HaManualScriptEditor extends LitElement {
 | 
			
		||||
    // deselect previous selected row
 | 
			
		||||
    this._sidebarConfig?.close?.();
 | 
			
		||||
    this._sidebarConfig = ev.detail;
 | 
			
		||||
    this._sidebarKey = JSON.stringify(this._sidebarConfig);
 | 
			
		||||
 | 
			
		||||
    // be sure the sidebar editor is recreated
 | 
			
		||||
    this._sidebarKey++;
 | 
			
		||||
 | 
			
		||||
    await this._sidebarElement?.updateComplete;
 | 
			
		||||
    this._sidebarElement?.focus();
 | 
			
		||||
@@ -537,6 +539,7 @@ export class HaManualScriptEditor extends LitElement {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this._sidebarConfig?.close();
 | 
			
		||||
      this._sidebarKey = 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -139,6 +139,12 @@ export class AssistPipelineDebug extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @property({ attribute: false }) public pipelineRun!: PipelineRun;
 | 
			
		||||
 | 
			
		||||
  private _audioElement?: HTMLAudioElement;
 | 
			
		||||
 | 
			
		||||
  private get _isPlaying(): boolean {
 | 
			
		||||
    return this._audioElement != null && !this._audioElement.paused;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected render(): TemplateResult {
 | 
			
		||||
    const lastRunStage: string = this.pipelineRun
 | 
			
		||||
      ? ["tts", "intent", "stt", "wake_word"].find(
 | 
			
		||||
@@ -349,8 +355,13 @@ export class AssistPipelineDebug extends LitElement {
 | 
			
		||||
              ${this.pipelineRun?.tts?.tts_output
 | 
			
		||||
                ? html`
 | 
			
		||||
                    <div class="card-actions">
 | 
			
		||||
                      <ha-button @click=${this._playTTS}>
 | 
			
		||||
                        Play Audio
 | 
			
		||||
                      <ha-button
 | 
			
		||||
                        .variant=${this._isPlaying ? "danger" : "brand"}
 | 
			
		||||
                        @click=${this._isPlaying
 | 
			
		||||
                          ? this._stopTTS
 | 
			
		||||
                          : this._playTTS}
 | 
			
		||||
                      >
 | 
			
		||||
                        ${this._isPlaying ? "Stop audio" : "Play audio"}
 | 
			
		||||
                      </ha-button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  `
 | 
			
		||||
@@ -373,14 +384,41 @@ export class AssistPipelineDebug extends LitElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _playTTS(): void {
 | 
			
		||||
    // Stop any existing audio first
 | 
			
		||||
    this._stopTTS();
 | 
			
		||||
 | 
			
		||||
    const url = this.pipelineRun!.tts!.tts_output!.url;
 | 
			
		||||
    const audio = new Audio(url);
 | 
			
		||||
    audio.addEventListener("error", () => {
 | 
			
		||||
    this._audioElement = new Audio(url);
 | 
			
		||||
 | 
			
		||||
    this._audioElement.addEventListener("error", () => {
 | 
			
		||||
      showAlertDialog(this, { title: "Error", text: "Error playing audio" });
 | 
			
		||||
    });
 | 
			
		||||
    audio.addEventListener("canplaythrough", () => {
 | 
			
		||||
      audio.play();
 | 
			
		||||
 | 
			
		||||
    this._audioElement.addEventListener("play", () => {
 | 
			
		||||
      this.requestUpdate();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this._audioElement.addEventListener("ended", () => {
 | 
			
		||||
      this.requestUpdate();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this._audioElement.addEventListener("canplaythrough", () => {
 | 
			
		||||
      this._audioElement!.play();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _stopTTS(): void {
 | 
			
		||||
    if (this._audioElement) {
 | 
			
		||||
      this._audioElement.pause();
 | 
			
		||||
      this._audioElement.currentTime = 0;
 | 
			
		||||
      this._audioElement = undefined;
 | 
			
		||||
      this.requestUpdate();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public disconnectedCallback(): void {
 | 
			
		||||
    super.disconnectedCallback();
 | 
			
		||||
    this._stopTTS();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static styles = css`
 | 
			
		||||
 
 | 
			
		||||
@@ -43,6 +43,8 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
 | 
			
		||||
 | 
			
		||||
  @state() private _supportedLanguages?: string[];
 | 
			
		||||
 | 
			
		||||
  @state() private _supportedConversationLanguages?: string[] | "*";
 | 
			
		||||
 | 
			
		||||
  public showDialog(params: VoiceAssistantPipelineDetailsDialogParams): void {
 | 
			
		||||
    this._params = params;
 | 
			
		||||
    this._error = undefined;
 | 
			
		||||
@@ -173,6 +175,8 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
 | 
			
		||||
            .data=${this._data}
 | 
			
		||||
            keys="conversation_engine,conversation_language,prefer_local_intents"
 | 
			
		||||
            @value-changed=${this._valueChanged}
 | 
			
		||||
            @supported-languages-changed=${this
 | 
			
		||||
              ._supportedConversationLanguagesChanged}
 | 
			
		||||
          ></assist-pipeline-detail-conversation>
 | 
			
		||||
          ${!this._cloudActive &&
 | 
			
		||||
          (this._data.tts_engine === "cloud" ||
 | 
			
		||||
@@ -214,7 +218,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
 | 
			
		||||
        <ha-button
 | 
			
		||||
          slot="primaryAction"
 | 
			
		||||
          @click=${this._updatePipeline}
 | 
			
		||||
          .disabled=${this._submitting}
 | 
			
		||||
          .loading=${this._submitting}
 | 
			
		||||
          dialogInitialFocus
 | 
			
		||||
        >
 | 
			
		||||
          ${this._params.pipeline?.id
 | 
			
		||||
@@ -245,6 +249,16 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
 | 
			
		||||
    this._data = { ...this._data, ...value };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _getDefaultConversationLanguage() {
 | 
			
		||||
    if (
 | 
			
		||||
      !this._supportedConversationLanguages ||
 | 
			
		||||
      this._supportedConversationLanguages === "*"
 | 
			
		||||
    ) {
 | 
			
		||||
      return this._supportedLanguages?.[0] ?? null;
 | 
			
		||||
    }
 | 
			
		||||
    return this._supportedConversationLanguages[0];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _updatePipeline() {
 | 
			
		||||
    this._submitting = true;
 | 
			
		||||
    try {
 | 
			
		||||
@@ -253,7 +267,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
 | 
			
		||||
        name: data.name!,
 | 
			
		||||
        language: data.language!,
 | 
			
		||||
        conversation_engine: data.conversation_engine!,
 | 
			
		||||
        conversation_language: data.conversation_language ?? null,
 | 
			
		||||
        conversation_language: this._getDefaultConversationLanguage(),
 | 
			
		||||
        prefer_local_intents: data.prefer_local_intents ?? true,
 | 
			
		||||
        stt_engine: data.stt_engine ?? null,
 | 
			
		||||
        stt_language: data.stt_language ?? null,
 | 
			
		||||
@@ -279,6 +293,23 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _supportedConversationLanguagesChanged(
 | 
			
		||||
    ev: CustomEvent<{ value: "*" | string[] | undefined }>
 | 
			
		||||
  ) {
 | 
			
		||||
    this._supportedConversationLanguages = ev.detail.value;
 | 
			
		||||
    if (
 | 
			
		||||
      this._data?.conversation_language &&
 | 
			
		||||
      !this._supportedConversationLanguages?.includes(
 | 
			
		||||
        this._data.conversation_language!
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      this._data = {
 | 
			
		||||
        ...this._data,
 | 
			
		||||
        conversation_language: this._getDefaultConversationLanguage(),
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static get styles(): CSSResultGroup {
 | 
			
		||||
    return [
 | 
			
		||||
      haStyleDialog,
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user