mirror of
				https://github.com/home-assistant/frontend.git
				synced 2025-10-31 06:29:43 +00:00 
			
		
		
		
	Compare commits
	
		
			48 Commits
		
	
	
		
			loading-an
			...
			ha-wa-dial
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 12b70c402d | ||
|   | 85c1f69d5e | ||
|   | 54c5c7ae26 | ||
|   | e7d6f44bdf | ||
|   | 35d5df5746 | ||
|   | cc9b429a25 | ||
|   | a1e2ac1d99 | ||
|   | 8ecddbc42c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6f70ef52a5 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 7dff02d7c8 | ||
|   | 8bbd7a6a06 | ||
|   | 5c73a06f76 | ||
|   | 9943dae82c | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 70bf049df0 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | f9d9fbb7f0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9cb84d3f37 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | c1bcf27cf8 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 164ec2a9b5 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 20001a551c | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | b7f85bf733 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | b303e9441b | ||
|   | 8f4bd0f620 | ||
|   | 596346bf59 | ||
|   | 769cea92aa | ||
|   | f825016514 | ||
|   | c6fd45bd6a | ||
|   | 6c4f4af75c | ||
|   | cd5c3ef2f6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 636a6fa02e | ||
|   | 21b83426d6 | ||
|   | c139ec22f9 | ||
|   | a6ef3a26da | ||
|   | 221ca56121 | ||
|   | 4e6e3629a8 | ||
|   | fe94ae0243 | ||
|   | 8a1a22d4bd | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 153a578986 | ||
| ![renovate[bot]](/assets/img/avatar_default.png)  | 04bb10d0a2 | ||
|   | 35e52de2c1 | ||
|   | b0862fddaa | ||
|   | 77735f5310 | ||
|   | e388756533 | ||
|   | e9ca9bb781 | ||
|   | e48918442c | ||
|   | 52f37f41f0 | ||
|   | 4687006fec | ||
|   | aca4ca3066 | ||
|   | 3a2c00622a | 
							
								
								
									
										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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|         with: | ||||
|           languages: ${{ matrix.language }} | ||||
|  | ||||
|       # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). | ||||
|       # If this step fails, then you should remove it and run the build manually (see below) | ||||
|       - name: Autobuild | ||||
|         uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 | ||||
|         uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|  | ||||
|       # ℹ️ Command-line programs to run using the OS shell. | ||||
|       # 📚 https://git.io/JvXDl | ||||
| @@ -57,4 +57,4 @@ jobs: | ||||
|       #   make release | ||||
|  | ||||
|       - name: Perform CodeQL Analysis | ||||
|         uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 | ||||
|         uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|   | ||||
							
								
								
									
										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 | ||||
|   | ||||
							
								
								
									
										26
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								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,7 +52,7 @@ | ||||
|     "@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", | ||||
|     "@home-assistant/webawesome": "3.0.0-beta.6.ha.6", | ||||
|     "@lezer/highlight": "1.2.2", | ||||
|     "@lit-labs/motion": "1.0.9", | ||||
|     "@lit-labs/observers": "2.0.6", | ||||
| @@ -148,16 +148,16 @@ | ||||
|     "xss": "1.0.15" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "7.28.4", | ||||
|     "@babel/core": "7.28.5", | ||||
|     "@babel/helper-define-polyfill-provider": "0.6.5", | ||||
|     "@babel/plugin-transform-runtime": "7.28.3", | ||||
|     "@babel/preset-env": "7.28.3", | ||||
|     "@babel/plugin-transform-runtime": "7.28.5", | ||||
|     "@babel/preset-env": "7.28.5", | ||||
|     "@bundle-stats/plugin-webpack-filter": "4.21.5", | ||||
|     "@lokalise/node-api": "15.3.1", | ||||
|     "@octokit/auth-oauth-device": "8.0.2", | ||||
|     "@octokit/plugin-retry": "8.0.2", | ||||
|     "@octokit/rest": "22.0.0", | ||||
|     "@rsdoctor/rspack-plugin": "1.3.3", | ||||
|     "@rsdoctor/rspack-plugin": "1.3.4", | ||||
|     "@rspack/core": "1.5.8", | ||||
|     "@rspack/dev-server": "1.1.4", | ||||
|     "@types/babel__plugin-transform-runtime": "7.9.5", | ||||
| @@ -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.2", | ||||
|     "babel-loader": "10.0.0", | ||||
|     "babel-plugin-template-html-minifier": "4.1.0", | ||||
|     "browserslist-useragent-regexp": "4.1.3", | ||||
| @@ -203,7 +203,7 @@ | ||||
|     "husky": "9.1.7", | ||||
|     "jsdom": "27.0.1", | ||||
|     "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", | ||||
| @@ -217,9 +217,9 @@ | ||||
|     "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.2", | ||||
|     "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" | ||||
|   | ||||
| @@ -88,9 +88,19 @@ export class HaChartBase extends LitElement { | ||||
|  | ||||
|   private _lastTapTime?: number; | ||||
|  | ||||
|   private _shouldResizeChart = false; | ||||
|  | ||||
|   // @ts-ignore | ||||
|   private _resizeController = new ResizeController(this, { | ||||
|     callback: () => this.chart?.resize(), | ||||
|     callback: () => { | ||||
|       if (this.chart) { | ||||
|         if (!this.chart.getZr().animation.isFinished()) { | ||||
|           this._shouldResizeChart = true; | ||||
|         } else { | ||||
|           this.chart.resize(); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   private _loading = false; | ||||
| @@ -366,6 +376,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) { | ||||
| @@ -945,6 +956,13 @@ export class HaChartBase extends LitElement { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _handleChartRenderFinished = () => { | ||||
|     if (this._shouldResizeChart) { | ||||
|       this.chart?.resize(); | ||||
|       this._shouldResizeChart = false; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|   | ||||
| @@ -147,6 +147,7 @@ class HaEntitiesPicker extends LitElement { | ||||
|           .createDomains=${this.createDomains} | ||||
|           .required=${this.required && !currentEntities.length} | ||||
|           @value-changed=${this._addEntity} | ||||
|           add-button | ||||
|         ></ha-entity-picker> | ||||
|       </div> | ||||
|     `; | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import "@home-assistant/webawesome/dist/components/drawer/drawer"; | ||||
| import { css, html, LitElement, type PropertyValues } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
|  | ||||
| export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | ||||
|  | ||||
| @@ -37,49 +38,61 @@ export class HaBottomSheet extends LitElement { | ||||
|         @wa-after-hide=${this._handleAfterHide} | ||||
|         without-header | ||||
|       > | ||||
|         <slot></slot> | ||||
|         <slot name="header"></slot> | ||||
|         <div 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) | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     :host([flexcontent]) wa-drawer::part(body) { | ||||
|       display: flex; | ||||
|     } | ||||
|   `; | ||||
|   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; | ||||
|       } | ||||
|       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; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| 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; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -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,7 +19,7 @@ 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") | ||||
| @@ -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,130 @@ export class HaGenericPicker extends LitElement { | ||||
|   @property({ attribute: "not-found-label", type: String }) | ||||
|   public notFoundLabel?: string; | ||||
|  | ||||
|   @query("ha-picker-field") private _field?: HaPickerField; | ||||
|   /** 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="bottom-start" | ||||
|                 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.hass.localize( | ||||
|                   "ui.components.target-picker.add_target" | ||||
|                 )} | ||||
|               > | ||||
|               </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.hass.localize( | ||||
|                   "ui.components.target-picker.add_target" | ||||
|                 )} | ||||
|               > | ||||
|                 ${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")} | ||||
|         .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 +200,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 +239,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 +296,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,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 | ||||
| @@ -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,23 +102,59 @@ 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")} | ||||
|         @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( | ||||
|     ( | ||||
| @@ -159,94 +204,56 @@ 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; | ||||
|  | ||||
|     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 }, 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) { | ||||
| @@ -260,17 +267,266 @@ 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(); | ||||
|     if (this._selectedItemIndex === -1) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // if filter button is focused | ||||
|     ev.preventDefault(); | ||||
|  | ||||
|     const item: any = this._virtualizerElement?.items[this._selectedItemIndex]; | ||||
|     if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       fireEvent(this, "value-changed", { value: item.id }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _resetSelectedItem() { | ||||
|     this._virtualizerElement | ||||
|       ?.querySelector(".selected") | ||||
|       ?.classList.remove("selected"); | ||||
|     this._selectedItemIndex = -1; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       :host { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         padding-top: var(--ha-space-3); | ||||
|         flex: 1; | ||||
|       } | ||||
|  | ||||
|       ha-textfield { | ||||
|         padding: 0 var(--ha-space-3); | ||||
|         margin-bottom: var(--ha-space-3); | ||||
|       } | ||||
|  | ||||
|       :host([mode="dialog"]) ha-textfield { | ||||
|         padding: 0 var(--ha-space-4); | ||||
|       } | ||||
|  | ||||
|       ha-combo-box-item { | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       ha-combo-box-item.selected { | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         ha-combo-box-item.selected { | ||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer { | ||||
|         flex: 1; | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer:focus-visible { | ||||
|         outline: none; | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer.scrolled { | ||||
|         border-top: 1px solid var(--ha-color-border-neutral-quiet); | ||||
|       } | ||||
|  | ||||
|       .bottom-padding { | ||||
|         height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       .empty { | ||||
|         text-align: center; | ||||
|       } | ||||
|  | ||||
|       .combo-box-row { | ||||
|         display: flex; | ||||
|         width: 100%; | ||||
|         align-items: center; | ||||
|         box-sizing: border-box; | ||||
|         min-height: 36px; | ||||
|       } | ||||
|       .combo-box-row.current-value { | ||||
|         background-color: var(--ha-color-fill-primary-quiet-resting); | ||||
|       } | ||||
|  | ||||
|       .combo-box-row.selected { | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         .combo-box-row.selected { | ||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -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,19 @@ | ||||
| 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 } from "lit"; | ||||
| import { | ||||
|   customElement, | ||||
|   eventOptions, | ||||
|   property, | ||||
|   query, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import { ifDefined } from "lit/directives/if-defined"; | ||||
| 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"; | ||||
|  | ||||
| @@ -25,6 +32,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full"; | ||||
|  * | ||||
|  * @slot header - Replace the entire header area. | ||||
|  * @slot headerNavigationIcon - Leading header action (e.g. close/back button). | ||||
|  * @slot headerTitle - Custom title content (used when header-title is not set). | ||||
|  * @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set). | ||||
|  * @slot headerActionItems - Trailing header actions (e.g. buttons, menus). | ||||
|  * @slot - Dialog content body. | ||||
|  * @slot footer - Dialog footer content. | ||||
| @@ -46,8 +55,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full"; | ||||
|  * @attr {boolean} open - Controls the dialog open state. | ||||
|  * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium". | ||||
|  * @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false. | ||||
|  * @attr {string} header-title - Header title text when no custom title slot is provided. | ||||
|  * @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided. | ||||
|  * @attr {string} header-title - Header title text. If not set, the headerTitle slot is used. | ||||
|  * @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used. | ||||
|  * @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below". | ||||
|  * @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts. | ||||
|  * | ||||
| @@ -66,6 +75,12 @@ export type DialogWidth = "small" | "medium" | "large" | "full"; | ||||
| export class HaWaDialog extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: "aria-labelledby" }) | ||||
|   public ariaLabelledBy?: string; | ||||
|  | ||||
|   @property({ attribute: "aria-describedby" }) | ||||
|   public ariaDescribedBy?: string; | ||||
|  | ||||
|   @property({ type: Boolean, reflect: true }) | ||||
|   public open = false; | ||||
|  | ||||
| @@ -75,11 +90,11 @@ export class HaWaDialog extends LitElement { | ||||
|   @property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" }) | ||||
|   public preventScrimClose = false; | ||||
|  | ||||
|   @property({ type: String, attribute: "header-title" }) | ||||
|   public headerTitle = ""; | ||||
|   @property({ attribute: "header-title" }) | ||||
|   public headerTitle?: string; | ||||
|  | ||||
|   @property({ type: String, attribute: "header-subtitle" }) | ||||
|   public headerSubtitle = ""; | ||||
|   @property({ attribute: "header-subtitle" }) | ||||
|   public headerSubtitle?: string; | ||||
|  | ||||
|   @property({ type: String, attribute: "header-subtitle-position" }) | ||||
|   public headerSubtitlePosition: "above" | "below" = "below"; | ||||
| @@ -90,6 +105,13 @@ export class HaWaDialog extends LitElement { | ||||
|   @state() | ||||
|   private _open = false; | ||||
|  | ||||
|   @query(".body") public bodyContainer!: HTMLDivElement; | ||||
|  | ||||
|   @query("wa-dialog") private _waDialog?: any; | ||||
|  | ||||
|   @state() | ||||
|   private _bodyScrolled = false; | ||||
|  | ||||
|   protected updated( | ||||
|     changedProperties: Map<string | number | symbol, unknown> | ||||
|   ): void { | ||||
| @@ -100,17 +122,50 @@ export class HaWaDialog extends LitElement { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _setAriaAttributes() { | ||||
|     const internalDialog = this._waDialog?.shadowRoot?.querySelector( | ||||
|       ".dialog" | ||||
|     ) as HTMLDialogElement | null; | ||||
|  | ||||
|     if (internalDialog) { | ||||
|       const labelledBy = | ||||
|         this.ariaLabelledBy || | ||||
|         (this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined); | ||||
|  | ||||
|       if (labelledBy) { | ||||
|         internalDialog.setAttribute("aria-labelledby", labelledBy); | ||||
|       } else { | ||||
|         internalDialog.removeAttribute("aria-labelledby"); | ||||
|       } | ||||
|  | ||||
|       if (this.ariaDescribedBy) { | ||||
|         internalDialog.setAttribute("aria-describedby", this.ariaDescribedBy); | ||||
|       } else { | ||||
|         internalDialog.removeAttribute("aria-describedby"); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <wa-dialog | ||||
|         .open=${this._open} | ||||
|         .lightDismiss=${!this.preventScrimClose} | ||||
|         without-header | ||||
|         aria-labelledby=${ifDefined( | ||||
|           this.ariaLabelledBy || | ||||
|             (this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined) | ||||
|         )} | ||||
|         aria-describedby=${ifDefined(this.ariaDescribedBy)} | ||||
|         @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" | ||||
| @@ -118,18 +173,18 @@ export class HaWaDialog extends LitElement { | ||||
|                 .path=${mdiClose} | ||||
|               ></ha-icon-button> | ||||
|             </slot> | ||||
|             ${this.headerTitle | ||||
|               ? html`<span slot="title" class="title"> | ||||
|             ${this.headerTitle !== undefined | ||||
|               ? html`<span slot="title" class="title" id="ha-wa-dialog-title"> | ||||
|                   ${this.headerTitle} | ||||
|                 </span>` | ||||
|               : nothing} | ||||
|             ${this.headerSubtitle | ||||
|               : html`<slot name="headerTitle" slot="title"></slot>`} | ||||
|             ${this.headerSubtitle !== undefined | ||||
|               ? html`<span slot="subtitle">${this.headerSubtitle}</span>` | ||||
|               : nothing} | ||||
|               : html`<slot name="headerSubtitle" slot="subtitle"></slot>`} | ||||
|             <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> | ||||
| @@ -143,9 +198,15 @@ export class HaWaDialog extends LitElement { | ||||
|  | ||||
|     await this.updateComplete; | ||||
|  | ||||
|     this._setAriaAttributes(); | ||||
|  | ||||
|     (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); | ||||
|   }; | ||||
|  | ||||
|   private _handleAfterShow = () => { | ||||
|     fireEvent(this, "after-show"); | ||||
|   }; | ||||
|  | ||||
|   private _handleAfterHide = () => { | ||||
|     this._open = false; | ||||
|     fireEvent(this, "closed"); | ||||
| @@ -156,6 +217,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 +238,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 +259,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 +277,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 +351,7 @@ export class HaWaDialog extends LitElement { | ||||
|       } | ||||
|       :host([flexcontent]) .body { | ||||
|         max-width: 100%; | ||||
|         flex: 1; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
| @@ -312,6 +380,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,6 +16,7 @@ import memoizeOne from "memoize-one"; | ||||
| import { computeCssColor } from "../../common/color/compute-color"; | ||||
| import { hex2rgb } from "../../common/color/convert-color"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { slugify } from "../../common/string/slugify"; | ||||
| import { | ||||
|   computeDeviceName, | ||||
|   computeDeviceNameDisplay, | ||||
| @@ -102,7 +103,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 +115,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 +131,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> | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -352,6 +352,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; | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
| @@ -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"); | ||||
|  | ||||
|   | ||||
| @@ -44,7 +44,8 @@ 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[]; | ||||
|  | ||||
|   | ||||
| @@ -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[]; | ||||
|  | ||||
|   | ||||
| @@ -37,7 +37,8 @@ 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 _warnings?: 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")} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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"), | ||||
|   | ||||
| @@ -1,15 +1,14 @@ | ||||
| import { mdiClose, mdiOpenInNew } from "@mdi/js"; | ||||
| import { mdiOpenInNew } from "@mdi/js"; | ||||
| import type { CSSResultGroup } from "lit"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state, query } from "lit/decorators"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { fireEvent } from "../../../common/dom/fire_event"; | ||||
| import { isNavigationClick } from "../../../common/dom/is-navigation-click"; | ||||
| import "../../../components/ha-alert"; | ||||
| import "../../../components/ha-md-dialog"; | ||||
| import type { HaMdDialog } from "../../../components/ha-md-dialog"; | ||||
| import "../../../components/ha-wa-dialog"; | ||||
| import "../../../components/ha-button"; | ||||
| import "../../../components/ha-svg-icon"; | ||||
| import "../../../components/ha-dialog-header"; | ||||
| import "../../../components/ha-dialog-footer"; | ||||
| import "./dialog-repairs-issue-subtitle"; | ||||
| import "../../../components/ha-markdown"; | ||||
| import type { RepairsIssue } from "../../../data/repairs"; | ||||
| @@ -26,11 +25,12 @@ class DialogRepairsIssue extends LitElement { | ||||
|  | ||||
|   @state() private _params?: RepairsIssueDialogParams; | ||||
|  | ||||
|   @query("ha-md-dialog") private _dialog?: HaMdDialog; | ||||
|   @state() private _open = false; | ||||
|  | ||||
|   public showDialog(params: RepairsIssueDialogParams): void { | ||||
|     this._params = params; | ||||
|     this._issue = this._params.issue; | ||||
|     this._open = true; | ||||
|   } | ||||
|  | ||||
|   private _dialogClosed() { | ||||
| @@ -44,7 +44,7 @@ class DialogRepairsIssue extends LitElement { | ||||
|   } | ||||
|  | ||||
|   public closeDialog() { | ||||
|     this._dialog?.close(); | ||||
|     this._open = false; | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
| @@ -62,32 +62,19 @@ class DialogRepairsIssue extends LitElement { | ||||
|       ) || this.hass!.localize("ui.panel.config.repairs.dialog.title"); | ||||
|  | ||||
|     return html` | ||||
|       <ha-md-dialog | ||||
|         open | ||||
|         @closed=${this._dialogClosed} | ||||
|         aria-labelledby="dialog-repairs-issue-title" | ||||
|       <ha-wa-dialog | ||||
|         .hass=${this.hass} | ||||
|         .open=${this._open} | ||||
|         header-title=${dialogTitle} | ||||
|         aria-describedby="dialog-repairs-issue-description" | ||||
|         @closed=${this._dialogClosed} | ||||
|       > | ||||
|         <ha-dialog-header slot="headline"> | ||||
|           <ha-icon-button | ||||
|             slot="navigationIcon" | ||||
|             .label=${this.hass.localize("ui.common.close") ?? "Close"} | ||||
|             .path=${mdiClose} | ||||
|             @click=${this.closeDialog} | ||||
|           ></ha-icon-button> | ||||
|           <span | ||||
|             slot="title" | ||||
|             id="dialog-repairs-issue-title" | ||||
|             .title=${dialogTitle} | ||||
|             >${dialogTitle}</span | ||||
|           > | ||||
|           <dialog-repairs-issue-subtitle | ||||
|             slot="subtitle" | ||||
|             .hass=${this.hass} | ||||
|             .issue=${this._issue} | ||||
|           ></dialog-repairs-issue-subtitle> | ||||
|         </ha-dialog-header> | ||||
|         <div slot="content" class="dialog-content"> | ||||
|         <dialog-repairs-issue-subtitle | ||||
|           slot="headerSubtitle" | ||||
|           .hass=${this.hass} | ||||
|           .issue=${this._issue} | ||||
|         ></dialog-repairs-issue-subtitle> | ||||
|         <div class="dialog-content"> | ||||
|           ${this._issue.breaks_in_ha_version | ||||
|             ? html` | ||||
|                 <ha-alert alert-type="warning"> | ||||
| @@ -122,8 +109,12 @@ class DialogRepairsIssue extends LitElement { | ||||
|               ` | ||||
|             : ""} | ||||
|         </div> | ||||
|         <div slot="actions"> | ||||
|           <ha-button appearance="plain" @click=${this._ignoreIssue}> | ||||
|         <ha-dialog-footer slot="footer"> | ||||
|           <ha-button | ||||
|             slot="secondaryAction" | ||||
|             appearance="plain" | ||||
|             @click=${this._ignoreIssue} | ||||
|           > | ||||
|             ${this._issue!.ignored | ||||
|               ? this.hass!.localize("ui.panel.config.repairs.dialog.unignore") | ||||
|               : this.hass!.localize("ui.panel.config.repairs.dialog.ignore")} | ||||
| @@ -131,6 +122,7 @@ class DialogRepairsIssue extends LitElement { | ||||
|           ${this._issue.learn_more_url | ||||
|             ? html` | ||||
|                 <ha-button | ||||
|                   slot="primaryAction" | ||||
|                   appearance="filled" | ||||
|                   rel="noopener noreferrer" | ||||
|                   href=${learnMoreUrlIsHomeAssistant | ||||
| @@ -149,8 +141,8 @@ class DialogRepairsIssue extends LitElement { | ||||
|                 </ha-button> | ||||
|               ` | ||||
|             : ""} | ||||
|         </div> | ||||
|       </ha-md-dialog> | ||||
|         </ha-dialog-footer> | ||||
|       </ha-wa-dialog> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -34,6 +34,7 @@ import { fireEvent } from "../../../../common/dom/fire_event"; | ||||
| import { measureTextWidth } from "../../../../util/text"; | ||||
| import "../../../../components/ha-icon-button"; | ||||
| import { storage } from "../../../../common/decorators/storage"; | ||||
| import { listenMediaQuery } from "../../../../common/dom/media_query"; | ||||
|  | ||||
| @customElement("hui-energy-devices-graph-card") | ||||
| export class HuiEnergyDevicesGraphCard | ||||
| @@ -56,6 +57,8 @@ export class HuiEnergyDevicesGraphCard | ||||
|   }) | ||||
|   private _chartType: "bar" | "pie" = "bar"; | ||||
|  | ||||
|   @state() private _isMobile = false; | ||||
|  | ||||
|   private _compoundStats: string[] = []; | ||||
|  | ||||
|   protected hassSubscribeRequiredHostProps = ["_config"]; | ||||
| @@ -68,6 +71,12 @@ export class HuiEnergyDevicesGraphCard | ||||
|         this._data = data; | ||||
|         this._getStatistics(data); | ||||
|       }), | ||||
|       listenMediaQuery( | ||||
|         "all and (max-width: 450px), all and (max-height: 500px)", | ||||
|         (matches) => { | ||||
|           this._isMobile = matches; | ||||
|         } | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
| @@ -154,9 +163,6 @@ export class HuiEnergyDevicesGraphCard | ||||
|         yAxis: { show: false }, | ||||
|       }; | ||||
|       if (chartType === "bar") { | ||||
|         const isMobile = window.matchMedia( | ||||
|           "all and (max-width: 450px), all and (max-height: 500px)" | ||||
|         ).matches; | ||||
|         options.xAxis = { | ||||
|           show: true, | ||||
|           type: "value", | ||||
| @@ -175,7 +181,7 @@ export class HuiEnergyDevicesGraphCard | ||||
|             fontSize: 12, | ||||
|             margin: 5, | ||||
|             width: Math.min( | ||||
|               isMobile ? 100 : 200, | ||||
|               this._isMobile ? 100 : 200, | ||||
|               Math.max( | ||||
|                 ...(data[0]?.data?.map( | ||||
|                   (d: any) => | ||||
| @@ -235,8 +241,14 @@ export class HuiEnergyDevicesGraphCard | ||||
|           this._chartType === "pie" | ||||
|             ? { | ||||
|                 formatter: ({ name }) => this._getDeviceName(name), | ||||
|                 overflow: "break", | ||||
|                 alignTo: this._isMobile ? "edge" : "none", | ||||
|                 edgeDistance: 1, | ||||
|               } | ||||
|             : undefined, | ||||
|         labelLine: { | ||||
|           length2: 10, | ||||
|         }, | ||||
|       } as BarSeriesOption | PieSeriesOption, | ||||
|     ]; | ||||
|  | ||||
|   | ||||
| @@ -660,7 +660,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       --mdc-icon-size: 48px; | ||||
|       --mdc-icon-size: var(--ha-space-12); | ||||
|       color: var(--tile-color); | ||||
|     } | ||||
|     .picture .icon-container::before { | ||||
| @@ -729,13 +729,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { | ||||
|     } | ||||
|     hui-card-features { | ||||
|       --feature-color: var(--tile-color); | ||||
|       padding: 0 12px 12px 12px; | ||||
|       padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3); | ||||
|     } | ||||
|     .container.horizontal hui-card-features { | ||||
|       width: calc(50% - var(--column-gap, 0px) / 2 - 12px); | ||||
|       width: calc( | ||||
|         50% - var(--column-gap, var(--ha-space-0)) / 2 - var(--ha-space-3) | ||||
|       ); | ||||
|       flex: none; | ||||
|       --feature-height: 36px; | ||||
|       padding: 0 12px; | ||||
|       --feature-height: var(--ha-space-9); | ||||
|       padding: 0 var(--ha-space-3); | ||||
|       padding-inline-start: 0; | ||||
|     } | ||||
|     .alert-badge { | ||||
| @@ -748,18 +750,18 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       gap: var(--ha-space-2); | ||||
|       padding: 8px; | ||||
|       padding: var(--ha-space-2); | ||||
|       pointer-events: none; | ||||
|       z-index: 1; | ||||
|     } | ||||
|     .alert { | ||||
|       background-color: var(--orange-color); | ||||
|       border-radius: var(--ha-border-radius-lg); | ||||
|       width: 24px; | ||||
|       height: 24px; | ||||
|       width: var(--ha-space-6); | ||||
|       height: var(--ha-space-6); | ||||
|       padding: 2px; | ||||
|       box-sizing: border-box; | ||||
|       --mdc-icon-size: 16px; | ||||
|       --mdc-icon-size: var(--ha-space-4); | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|   | ||||
| @@ -447,13 +447,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard { | ||||
|     } | ||||
|     hui-card-features { | ||||
|       --feature-color: var(--tile-color); | ||||
|       padding: 0 12px 12px 12px; | ||||
|       padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3); | ||||
|     } | ||||
|     .container.horizontal hui-card-features { | ||||
|       width: calc(50% - var(--column-gap, 0px) / 2 - 12px); | ||||
|       width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3)); | ||||
|       flex: none; | ||||
|       --feature-height: 36px; | ||||
|       padding: 0 12px; | ||||
|       --feature-height: var(--ha-space-9); | ||||
|       padding: 0 var(--ha-space-3); | ||||
|       padding-inline-start: 0; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -563,7 +563,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { | ||||
|         .name, | ||||
|         .attribute { | ||||
|           font-size: var(--ha-font-size-m); | ||||
|           line-height: 1; | ||||
|           line-height: var(--ha-line-height-condensed); | ||||
|         } | ||||
|  | ||||
|         .name-state { | ||||
| @@ -729,7 +729,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { | ||||
|         .short .state, | ||||
|         .short .temp-attribute .temp { | ||||
|           font-size: 24px; | ||||
|           line-height: 1.25; | ||||
|           line-height: var(--ha-line-height-condensed); | ||||
|         } | ||||
|  | ||||
|         .short .content + .forecast { | ||||
|   | ||||
| @@ -174,10 +174,10 @@ export class HuiEntityEditor extends LitElement { | ||||
|             </div> | ||||
|           </ha-sortable>`} | ||||
|       <ha-entity-picker | ||||
|         class="add-entity" | ||||
|         .hass=${this.hass} | ||||
|         .entityFilter=${this.entityFilter} | ||||
|         @value-changed=${this._addEntity} | ||||
|         add-button | ||||
|       ></ha-entity-picker> | ||||
|     `; | ||||
|   } | ||||
| @@ -226,13 +226,6 @@ export class HuiEntityEditor extends LitElement { | ||||
|     ha-entity-picker { | ||||
|       margin-top: 8px; | ||||
|     } | ||||
|     .add-entity { | ||||
|       display: block; | ||||
|       margin-left: 31px; | ||||
|       margin-inline-start: 31px; | ||||
|       margin-inline-end: initial; | ||||
|       direction: var(--direction); | ||||
|     } | ||||
|     .entity { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|   | ||||
| @@ -495,7 +495,7 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|       flex-direction: column; | ||||
|     } | ||||
|     ha-button-menu { | ||||
|       margin-top: 8px; | ||||
|       margin-top: var(--ha-space-2); | ||||
|     } | ||||
|     .feature { | ||||
|       display: flex; | ||||
| @@ -504,8 +504,8 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|     .feature .handle { | ||||
|       cursor: move; /* fallback if grab cursor is unsupported */ | ||||
|       cursor: grab; | ||||
|       padding-right: 8px; | ||||
|       padding-inline-end: 8px; | ||||
|       padding-right: var(--ha-space-2); | ||||
|       padding-inline-end: var(--ha-space-2); | ||||
|       padding-inline-start: initial; | ||||
|       direction: var(--direction); | ||||
|     } | ||||
| @@ -514,7 +514,7 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|     } | ||||
|  | ||||
|     .feature-content { | ||||
|       height: 60px; | ||||
|       height: var(--ha-space-15); | ||||
|       font-size: var(--ha-font-size-l); | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
| @@ -529,7 +529,7 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|  | ||||
|     .remove-icon, | ||||
|     .edit-icon { | ||||
|       --mdc-icon-button-size: 36px; | ||||
|       --mdc-icon-button-size: var(--ha-space-9); | ||||
|       color: var(--secondary-text-color); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,20 +1,12 @@ | ||||
| import "@material/mwc-menu/mwc-menu-surface"; | ||||
| import { | ||||
|   mdiDelete, | ||||
|   mdiDragHorizontalVariant, | ||||
|   mdiPencil, | ||||
|   mdiPlus, | ||||
| } from "@mdi/js"; | ||||
| import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; | ||||
| import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { customElement, property } from "lit/decorators"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import { fireEvent } from "../../../../common/dom/fire_event"; | ||||
| import { preventDefault } from "../../../../common/dom/prevent_default"; | ||||
| import { stopPropagation } from "../../../../common/dom/stop_propagation"; | ||||
| import { computeStateName } from "../../../../common/entity/compute_state_name"; | ||||
| import "../../../../components/entity/ha-entity-picker"; | ||||
| import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker"; | ||||
| import "../../../../components/ha-button"; | ||||
| import "../../../../components/ha-icon-button"; | ||||
| import "../../../../components/ha-sortable"; | ||||
| @@ -36,14 +28,6 @@ export class HuiHeadingBadgesEditor extends LitElement { | ||||
|   @property({ attribute: false }) | ||||
|   public badges?: LovelaceHeadingBadgeConfig[]; | ||||
|  | ||||
|   @query(".add-container", true) private _addContainer?: HTMLDivElement; | ||||
|  | ||||
|   @query("ha-entity-picker") private _entityPicker?: HaEntityPicker; | ||||
|  | ||||
|   @state() private _addMode = false; | ||||
|  | ||||
|   private _opened = false; | ||||
|  | ||||
|   private _badgesKeys = new WeakMap<LovelaceHeadingBadgeConfig, string>(); | ||||
|  | ||||
|   private _getKey(badge: LovelaceHeadingBadgeConfig) { | ||||
| @@ -125,32 +109,6 @@ export class HuiHeadingBadgesEditor extends LitElement { | ||||
|           ` | ||||
|         : nothing} | ||||
|       <div class="add-container"> | ||||
|         <ha-button | ||||
|           data-add-entity | ||||
|           appearance="filled" | ||||
|           @click=${this._addEntity} | ||||
|         > | ||||
|           <ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon> | ||||
|           ${this.hass!.localize(`ui.panel.lovelace.editor.entities.add`)} | ||||
|         </ha-button> | ||||
|         ${this._renderPicker()} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderPicker() { | ||||
|     if (!this._addMode) { | ||||
|       return nothing; | ||||
|     } | ||||
|     return html` | ||||
|       <mwc-menu-surface | ||||
|         open | ||||
|         .anchor=${this._addContainer} | ||||
|         @closed=${this._onClosed} | ||||
|         @opened=${this._onOpened} | ||||
|         @opened-changed=${this._openedChanged} | ||||
|         @input=${stopPropagation} | ||||
|       > | ||||
|         <ha-entity-picker | ||||
|           .hass=${this.hass} | ||||
|           id="input" | ||||
| @@ -161,39 +119,15 @@ export class HuiHeadingBadgesEditor extends LitElement { | ||||
|             "ui.components.entity.entity-picker.choose_entity" | ||||
|           )} | ||||
|           @value-changed=${this._entityPicked} | ||||
|           .value=${undefined} | ||||
|           @click=${preventDefault} | ||||
|           allow-custom-entity | ||||
|           add-button | ||||
|         ></ha-entity-picker> | ||||
|       </mwc-menu-surface> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _onClosed(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     ev.target.open = true; | ||||
|   } | ||||
|  | ||||
|   private async _onOpened() { | ||||
|     if (!this._addMode) { | ||||
|       return; | ||||
|     } | ||||
|     await this._entityPicker?.focus(); | ||||
|     await this._entityPicker?.open(); | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   private _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { | ||||
|     if (this._opened && !ev.detail.value) { | ||||
|       this._opened = false; | ||||
|       this._addMode = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async _addEntity(ev): Promise<void> { | ||||
|     ev.stopPropagation(); | ||||
|     this._addMode = true; | ||||
|   } | ||||
|  | ||||
|   private _entityPicked(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     if (!ev.detail.value) { | ||||
|   | ||||
| @@ -115,6 +115,7 @@ export class HuiEntitiesCardRowEditor extends LitElement { | ||||
|         class="add-entity" | ||||
|         .hass=${this.hass} | ||||
|         @value-changed=${this._addEntity} | ||||
|         add-button | ||||
|       ></ha-entity-picker> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -72,8 +72,7 @@ import { | ||||
| } from "../../dialogs/quick-bar/show-dialog-quick-bar"; | ||||
| import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog"; | ||||
| import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; | ||||
| import { ViewTransitionMixin } from "../../mixins/view-transition-mixin"; | ||||
| import { haStyle, haStyleViewTransitions } from "../../resources/styles"; | ||||
| import { haStyle } from "../../resources/styles"; | ||||
| import type { HomeAssistant, PanelInfo } from "../../types"; | ||||
| import { documentationUrl } from "../../util/documentation-url"; | ||||
| import { showToast } from "../../util/toast"; | ||||
| @@ -115,7 +114,7 @@ interface SubActionItem { | ||||
| } | ||||
|  | ||||
| @customElement("hui-root") | ||||
| class HUIRoot extends ViewTransitionMixin(LitElement) { | ||||
| class HUIRoot extends LitElement { | ||||
|   @property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>; | ||||
|  | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
| @@ -497,7 +496,6 @@ class HUIRoot extends ViewTransitionMixin(LitElement) { | ||||
|         class=${classMap({ | ||||
|           "edit-mode": this._editMode, | ||||
|           narrow: this.narrow, | ||||
|           loading: !this._loaded, | ||||
|         })} | ||||
|       > | ||||
|         <div class="header"> | ||||
| @@ -1167,45 +1165,43 @@ class HUIRoot extends ViewTransitionMixin(LitElement) { | ||||
|     // Recreate a new element to clear the applied themes. | ||||
|     const root = this._viewRoot; | ||||
|  | ||||
|     this.startViewTransition(() => { | ||||
|       if (root.lastChild) { | ||||
|         root.removeChild(root.lastChild); | ||||
|       } | ||||
|     if (root.lastChild) { | ||||
|       root.removeChild(root.lastChild); | ||||
|     } | ||||
|  | ||||
|       if (viewIndex === "hass-unused-entities") { | ||||
|         const unusedEntities = document.createElement("hui-unused-entities"); | ||||
|         // Wait for promise to resolve so that the element has been upgraded. | ||||
|         import("./editor/unused-entities/hui-unused-entities").then(() => { | ||||
|           unusedEntities.hass = this.hass!; | ||||
|           unusedEntities.lovelace = this.lovelace!; | ||||
|           unusedEntities.narrow = this.narrow; | ||||
|         }); | ||||
|         root.appendChild(unusedEntities); | ||||
|         return; | ||||
|       } | ||||
|     if (viewIndex === "hass-unused-entities") { | ||||
|       const unusedEntities = document.createElement("hui-unused-entities"); | ||||
|       // Wait for promise to resolve so that the element has been upgraded. | ||||
|       import("./editor/unused-entities/hui-unused-entities").then(() => { | ||||
|         unusedEntities.hass = this.hass!; | ||||
|         unusedEntities.lovelace = this.lovelace!; | ||||
|         unusedEntities.narrow = this.narrow; | ||||
|       }); | ||||
|       root.appendChild(unusedEntities); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|       let view; | ||||
|       const viewConfig = this.config.views[viewIndex]; | ||||
|     let view; | ||||
|     const viewConfig = this.config.views[viewIndex]; | ||||
|  | ||||
|       if (!viewConfig) { | ||||
|         this.lovelace!.setEditMode(true); | ||||
|         return; | ||||
|       } | ||||
|     if (!viewConfig) { | ||||
|       this.lovelace!.setEditMode(true); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|       if (!force && this._viewCache![viewIndex]) { | ||||
|         view = this._viewCache![viewIndex]; | ||||
|       } else { | ||||
|         view = document.createElement("hui-view"); | ||||
|         view.index = viewIndex; | ||||
|         this._viewCache![viewIndex] = view; | ||||
|       } | ||||
|     if (!force && this._viewCache![viewIndex]) { | ||||
|       view = this._viewCache![viewIndex]; | ||||
|     } else { | ||||
|       view = document.createElement("hui-view"); | ||||
|       view.index = viewIndex; | ||||
|       this._viewCache![viewIndex] = view; | ||||
|     } | ||||
|  | ||||
|       view.lovelace = this.lovelace; | ||||
|       view.hass = this.hass; | ||||
|       view.narrow = this.narrow; | ||||
|     view.lovelace = this.lovelace; | ||||
|     view.hass = this.hass; | ||||
|     view.narrow = this.narrow; | ||||
|  | ||||
|       root.appendChild(view); | ||||
|     }); | ||||
|     root.appendChild(view); | ||||
|   } | ||||
|  | ||||
|   private _openShortcutDialog(ev: Event) { | ||||
| @@ -1216,21 +1212,12 @@ class HUIRoot extends ViewTransitionMixin(LitElement) { | ||||
|   static get styles(): CSSResultGroup { | ||||
|     return [ | ||||
|       haStyle, | ||||
|       haStyleViewTransitions, | ||||
|       css` | ||||
|         :host { | ||||
|           -ms-user-select: none; | ||||
|           -webkit-user-select: none; | ||||
|           -moz-user-select: none; | ||||
|         } | ||||
|  | ||||
|         @media (prefers-reduced-motion: no-preference) { | ||||
|           ::view-transition-new(hui-root-container) { | ||||
|             animation: fade-in var(--ha-animation-layout-duration) ease-out; | ||||
|             animation-delay: var(--ha-animation-layout-delay-base); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         .header { | ||||
|           background-color: var(--app-header-background-color); | ||||
|           color: var(--app-header-text-color, white); | ||||
| @@ -1419,10 +1406,6 @@ class HUIRoot extends ViewTransitionMixin(LitElement) { | ||||
|           padding-right: var(--safe-area-inset-right); | ||||
|           padding-inline-end: var(--safe-area-inset-right); | ||||
|           padding-bottom: var(--safe-area-inset-bottom); | ||||
|           view-transition-name: hui-root-container; | ||||
|         } | ||||
|         .loading hui-view-container { | ||||
|           opacity: 0; | ||||
|         } | ||||
|         .narrow hui-view-container { | ||||
|           padding-left: var(--safe-area-inset-left); | ||||
| @@ -1431,7 +1414,6 @@ class HUIRoot extends ViewTransitionMixin(LitElement) { | ||||
|         hui-view-container > * { | ||||
|           flex: 1 1 100%; | ||||
|           max-width: 100%; | ||||
|           view-transition-name: layout-fade-in; | ||||
|         } | ||||
|         /** | ||||
|          * In edit mode we have the tab bar on a new line * | ||||
|   | ||||
| @@ -38,7 +38,7 @@ class AdvancedModeRow extends LitElement { | ||||
|           </a> | ||||
|         </span> | ||||
|         <ha-switch | ||||
|           .checked=${this.coreUserData && this.coreUserData.showAdvanced} | ||||
|           .checked=${!!this.coreUserData && !!this.coreUserData.showAdvanced} | ||||
|           .disabled=${this.coreUserData === undefined} | ||||
|           @change=${this._advancedToggled} | ||||
|         ></ha-switch> | ||||
|   | ||||
| @@ -32,7 +32,8 @@ class EntityIdPickerRow extends LitElement { | ||||
|           ${this.hass.localize("ui.panel.profile.entity_id_picker.description")} | ||||
|         </span> | ||||
|         <ha-switch | ||||
|           .checked=${this.coreUserData && this.coreUserData.showEntityIdPicker} | ||||
|           .checked=${!!this.coreUserData && | ||||
|           !!this.coreUserData.showEntityIdPicker} | ||||
|           .disabled=${this.coreUserData === undefined} | ||||
|           @change=${this._toggled} | ||||
|         ></ha-switch> | ||||
|   | ||||
| @@ -199,56 +199,3 @@ export const baseEntrypointStyles = css` | ||||
|     width: 100vw; | ||||
|   } | ||||
| `; | ||||
|  | ||||
| export const haStyleViewTransitions = css` | ||||
|   @keyframes fade-in { | ||||
|     from { | ||||
|       opacity: 0; | ||||
|     } | ||||
|     to { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @keyframes fade-out { | ||||
|     from { | ||||
|       opacity: 1; | ||||
|     } | ||||
|     to { | ||||
|       opacity: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @media (prefers-reduced-motion: no-preference) { | ||||
|     /* Prevent root cross-fade during view transitions (pseudo-element) */ | ||||
|     ::view-transition-old(root) { | ||||
|       animation: none; | ||||
|     } | ||||
|     ::view-transition-new(root) { | ||||
|       animation: none; | ||||
|     } | ||||
|  | ||||
|     /* Elements leaving the view (loading screen) */ | ||||
|     ::view-transition-group(layout-fade-out) { | ||||
|       animation-duration: var(--ha-animation-layout-duration); | ||||
|       animation-timing-function: ease-out; | ||||
|     } | ||||
|     ::view-transition-old(layout-fade-out) { | ||||
|       animation: fade-out var(--ha-animation-layout-duration) ease-out; | ||||
|     } | ||||
|     ::view-transition-new(layout-fade-out) { | ||||
|       animation: none; | ||||
|     } | ||||
|  | ||||
|     /* New content entering (panels, subpages) | ||||
|        Uses base delay to be less abrupt and allow for elements to render */ | ||||
|     ::view-transition-group(layout-fade-in) { | ||||
|       animation-duration: var(--ha-animation-layout-duration); | ||||
|       animation-timing-function: ease-out; | ||||
|     } | ||||
|     ::view-transition-new(layout-fade-in) { | ||||
|       animation: fade-in var(--ha-animation-layout-duration) ease-out; | ||||
|       animation-delay: var(--ha-animation-layout-delay-base); | ||||
|     } | ||||
|   } | ||||
| `; | ||||
|   | ||||
| @@ -42,17 +42,6 @@ export const coreStyles = css` | ||||
|     --ha-space-18: 72px; | ||||
|     --ha-space-19: 76px; | ||||
|     --ha-space-20: 80px; | ||||
|  | ||||
|     /* Animation timing */ | ||||
|     --ha-animation-layout-duration: 350ms; | ||||
|     --ha-animation-layout-delay-base: 100ms; | ||||
|   } | ||||
|  | ||||
|   @media (prefers-reduced-motion: reduce) { | ||||
|     html { | ||||
|       --ha-animation-layout-duration: 0ms; | ||||
|       --ha-animation-layout-delay-base: 0ms; | ||||
|     } | ||||
|   } | ||||
| `; | ||||
|  | ||||
|   | ||||
| @@ -648,6 +648,7 @@ | ||||
|         "entity-picker": { | ||||
|           "choose_entity": "Choose entity", | ||||
|           "entity": "Entity", | ||||
|           "add": "Add entity", | ||||
|           "edit": "Edit", | ||||
|           "clear": "Clear", | ||||
|           "no_entities": "You don't have any entities", | ||||
| @@ -676,6 +677,9 @@ | ||||
|         }, | ||||
|         "entity-state-picker": { | ||||
|           "state": "State" | ||||
|         }, | ||||
|         "entity-state-content-picker": { | ||||
|           "add": "Add" | ||||
|         } | ||||
|       }, | ||||
|       "target-picker": { | ||||
| @@ -806,6 +810,7 @@ | ||||
|         "labels": "Labels", | ||||
|         "add_new_sugestion": "Add new label ''{name}''", | ||||
|         "add_new": "Add new label…", | ||||
|         "add": "Add label", | ||||
|         "no_labels": "You don't have any labels", | ||||
|         "no_match": "No matching labels found", | ||||
|         "failed_create_label": "Failed to create label." | ||||
| @@ -3915,7 +3920,6 @@ | ||||
|             "edit_yaml": "Edit in YAML", | ||||
|             "edit_ui": "Edit in visual editor", | ||||
|             "copy_to_clipboard": "Copy to clipboard", | ||||
|             "search_in": "Search · {group}", | ||||
|             "unknown_entity": "unknown entity", | ||||
|             "edit_unknown_device": "Editor not available for unknown device", | ||||
|             "switch_ui_yaml_error": "There are currently YAML errors in the automation, and it cannot be parsed. Switching to UI mode may cause pending changes to be lost. Press cancel to correct any errors before proceeding to prevent loss of pending changes, or continue if you are sure.", | ||||
| @@ -3930,6 +3934,7 @@ | ||||
|             "item_pasted": "{item} pasted", | ||||
|             "ctrl": "Ctrl", | ||||
|             "del": "Del", | ||||
|             "blocks": "Blocks", | ||||
|             "triggers": { | ||||
|               "name": "Triggers", | ||||
|               "header": "When", | ||||
| @@ -3937,7 +3942,7 @@ | ||||
|               "learn_more": "Learn more about triggers", | ||||
|               "triggered": "Triggered", | ||||
|               "add": "Add trigger", | ||||
|               "search": "Search trigger", | ||||
|               "empty_search": "No triggers found for {term}", | ||||
|               "id": "Trigger ID", | ||||
|               "id_helper": "Helps identify each run based on which trigger fired.", | ||||
|               "optional": "Optional", | ||||
| @@ -3958,14 +3963,16 @@ | ||||
|               "trigger": "Trigger", | ||||
|               "copied_to_clipboard": "Trigger copied to clipboard", | ||||
|               "cut_to_clipboard": "Trigger cut to clipboard", | ||||
|               "select": "Select a trigger", | ||||
|               "groups": { | ||||
|                 "device": { | ||||
|                   "label": "Device" | ||||
|                 }, | ||||
|                 "entity": { | ||||
|                   "label": "Entity", | ||||
|                   "description": "When something happens to an entity." | ||||
|                   "label": "Entity" | ||||
|                 }, | ||||
|                 "time_location": { | ||||
|                   "label": "Time and location", | ||||
|                   "description": "When someone enters or leaves a zone, or at a specific time." | ||||
|                   "label": "Time and location" | ||||
|                 }, | ||||
|                 "other": { | ||||
|                   "label": "Other triggers" | ||||
| @@ -4198,7 +4205,7 @@ | ||||
|               "description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.", | ||||
|               "learn_more": "Learn more about conditions", | ||||
|               "add": "Add condition", | ||||
|               "search": "Search condition", | ||||
|               "empty_search": "No conditions and blocks found for {term}", | ||||
|               "add_building_block": "Add building block", | ||||
|               "test": "Test", | ||||
|               "testing_error": "Condition did not pass", | ||||
| @@ -4220,21 +4227,22 @@ | ||||
|               "condition": "Condition", | ||||
|               "copied_to_clipboard": "Condition copied to clipboard", | ||||
|               "cut_to_clipboard": "Condition cut to clipboard", | ||||
|               "select": "Select a condition", | ||||
|               "groups": { | ||||
|                 "device": { | ||||
|                   "label": "Device" | ||||
|                 }, | ||||
|                 "entity": { | ||||
|                   "label": "Entity", | ||||
|                   "description": "If an entity is in a specific state." | ||||
|                   "label": "Entity" | ||||
|                 }, | ||||
|                 "time_location": { | ||||
|                   "label": "Time and location", | ||||
|                   "description": "If someone is in a zone or if the current time is before or after a specified time." | ||||
|                   "label": "Time and location" | ||||
|                 }, | ||||
|                 "other": { | ||||
|                   "label": "Other conditions" | ||||
|                 }, | ||||
|                 "building_blocks": { | ||||
|                   "label": "Building blocks", | ||||
|                   "description": "Build more complex conditions." | ||||
|                   "label": "Building blocks" | ||||
|                 } | ||||
|               }, | ||||
|               "type": { | ||||
| @@ -4365,7 +4373,7 @@ | ||||
|               "description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.", | ||||
|               "learn_more": "Learn more about actions", | ||||
|               "add": "Add action", | ||||
|               "search": "Search action", | ||||
|               "empty_search": "No actions and blocks found for {term}", | ||||
|               "add_building_block": "Add building block", | ||||
|               "invalid_action": "Invalid action", | ||||
|               "run": "Run action", | ||||
| @@ -4389,7 +4397,11 @@ | ||||
|               "action": "Action", | ||||
|               "copied_to_clipboard": "Action copied to clipboard", | ||||
|               "cut_to_clipboard": "Action cut to clipboard", | ||||
|               "select": "Select an action", | ||||
|               "groups": { | ||||
|                 "device_id": { | ||||
|                   "label": "Device" | ||||
|                 }, | ||||
|                 "helpers": { | ||||
|                   "label": "Helpers" | ||||
|                 }, | ||||
| @@ -4397,8 +4409,7 @@ | ||||
|                   "label": "Other actions" | ||||
|                 }, | ||||
|                 "building_blocks": { | ||||
|                   "label": "Building blocks", | ||||
|                   "description": "Build more complex sequences of actions." | ||||
|                   "label": "Building blocks" | ||||
|                 } | ||||
|               }, | ||||
|               "type": { | ||||
|   | ||||
| @@ -3,21 +3,8 @@ import { render } from "lit"; | ||||
|  | ||||
| export const removeLaunchScreen = () => { | ||||
|   const launchScreenElement = document.getElementById("ha-launch-screen"); | ||||
|   if (!launchScreenElement?.parentElement) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Use View Transition API if available and user doesn't prefer reduced motion | ||||
|   if ( | ||||
|     document.startViewTransition && | ||||
|     !window.matchMedia("(prefers-reduced-motion: reduce)").matches | ||||
|   ) { | ||||
|     document.startViewTransition(() => { | ||||
|       launchScreenElement.parentElement?.removeChild(launchScreenElement); | ||||
|     }); | ||||
|   } else { | ||||
|     // Fallback: Direct removal without transition | ||||
|     launchScreenElement.parentElement.removeChild(launchScreenElement); | ||||
|   if (launchScreenElement) { | ||||
|     launchScreenElement.parentElement!.removeChild(launchScreenElement); | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -26,7 +26,7 @@ describe("floorCompare", () => { | ||||
|   }); | ||||
|  | ||||
|   describe("floorCompare(entries)", () => { | ||||
|     it("sorts by level, then by name", () => { | ||||
|     it("sorts by level descending (highest to lowest), then by name", () => { | ||||
|       const entries = { | ||||
|         floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry, | ||||
|         floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry, | ||||
| @@ -35,13 +35,13 @@ describe("floorCompare", () => { | ||||
|       const floors = ["floor1", "floor2", "floor3"]; | ||||
|  | ||||
|       expect(floors.sort(floorCompare(entries))).toEqual([ | ||||
|         "floor3", | ||||
|         "floor1", | ||||
|         "floor2", | ||||
|         "floor1", | ||||
|         "floor3", | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it("treats null level as 9999, placing it at the end", () => { | ||||
|     it("treats null level as -9999, placing it at the end", () => { | ||||
|       const entries = { | ||||
|         floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry, | ||||
|         floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry, | ||||
| @@ -50,8 +50,8 @@ describe("floorCompare", () => { | ||||
|       const floors = ["floor2", "floor3", "floor1"]; | ||||
|  | ||||
|       expect(floors.sort(floorCompare(entries))).toEqual([ | ||||
|         "floor1", | ||||
|         "floor2", | ||||
|         "floor1", | ||||
|         "floor3", | ||||
|       ]); | ||||
|     }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user