mirror of
				https://github.com/home-assistant/frontend.git
				synced 2025-10-30 22:19:55 +00:00 
			
		
		
		
	Compare commits
	
		
			56 Commits
		
	
	
		
			copilot/al
			...
			loading-an
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 83512e62f5 | ||
|   | aa010bc6f0 | ||
|   | 19d6743f8c | ||
|   | e7f816b982 | ||
|   | 944ab1b3ce | ||
|   | 918e0f8383 | ||
|   | 146c2654b3 | ||
|   | 8af8d6cd3f | ||
|   | cf93fb7091 | ||
|   | 3ce7b42dc3 | ||
|   | 91f5a8beca | ||
|   | 50fc5645ae | ||
|   | bacc478e4a | ||
|   | e7bb2cc10c | ||
|   | 7b37e9e030 | ||
|   | 5da2abd720 | ||
|   | 61b34507ed | ||
|   | 49f916428d | ||
|   | 71b568076c | ||
|   | 4af4d86c53 | ||
|   | 13f6d2af1f | ||
|   | 9f1fd06def | ||
|   | d2f354ed71 | ||
|   | d612e29b31 | ||
|   | 6656fe7122 | ||
|   | ab4f7cef2b | ||
|   | ae929d57b6 | ||
|   | 6f8516aa4a | ||
|   | 74aa390229 | ||
|   | 944ed9f000 | ||
|   | d4a02dddf0 | ||
|   | 59b56822b8 | ||
|   | 0d0eb737c6 | ||
|   | 5338192c97 | ||
|   | 04e9d1bec3 | ||
|   | 1bfbd1ec09 | ||
|   | afebe1d588 | ||
|   | d0c527943d | ||
|   | 8f50e2c025 | ||
|   | a9219a8779 | ||
|   | 2a135c50ce | ||
|   | 36b11dbbcd | ||
|   | 37ea0a11fa | ||
|   | e9ab1c27d2 | ||
|   | 1ec0ff46c9 | ||
|   | 2b0fd53349 | ||
|   | 8c7643c524 | ||
|   | ff32bae8ea | ||
|   | 72cc53d960 | ||
|   | 2b6ce8c34e | ||
|   | f61ebe36b9 | ||
|   | 2c8e3762c6 | ||
|   | 89b86d0d69 | ||
|   | c60d038828 | ||
|   | f9e2d4ef95 | ||
|   | 2609133f54 | 
							
								
								
									
										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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | ||||
|         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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | ||||
|         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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|         uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 | ||||
|         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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|         uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 | ||||
|  | ||||
|       # ℹ️ 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 | ||||
|         uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 | ||||
|   | ||||
							
								
								
									
										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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | ||||
|         with: | ||||
|           name: wheels | ||||
|           path: dist/home_assistant_frontend*.whl | ||||
|           if-no-files-found: error | ||||
|  | ||||
|       - name: Upload translations | ||||
|         uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 | ||||
|         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | ||||
|         with: | ||||
|           name: translations | ||||
|           path: translations.tar.gz | ||||
|   | ||||
| @@ -5,14 +5,14 @@ subtitle: Dialogs provide important prompts in a user flow. | ||||
|  | ||||
| # Material Design 3 | ||||
|  | ||||
| Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guidelines. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview). | ||||
| Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guideliness. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview). | ||||
|  | ||||
| # Guidelines | ||||
|  | ||||
| ## Design | ||||
|  | ||||
| - Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead. | ||||
| - The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines. | ||||
| - Dialogs have a max width of 560px. Alert and confirmation dialogs got a fixed width of 320px. If you need more width, consider a dedicated page instead. | ||||
| - The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guideliness. | ||||
| - Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake. | ||||
| - Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu. | ||||
| - The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom. | ||||
| @@ -26,7 +26,7 @@ Our dialogs are based on the latest version of Material Design. Please note that | ||||
|  | ||||
| - A best practice is to always use a title, even if it is optional by Material guidelines. | ||||
| - People mainly read the title and a button. Put the most important information in those two. | ||||
| - Try to avoid user generated content in the title, this could make the title unreadably long. | ||||
| - Try to avoid user generated content in the title, this could make the title unreadable long. | ||||
| - If users become unsure, they read the description. Make sure this explains what will happen. | ||||
| - Strive for minimalism. | ||||
|  | ||||
|   | ||||
							
								
								
									
										28
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								package.json
									
									
									
									
									
								
							| @@ -28,8 +28,8 @@ | ||||
|   "dependencies": { | ||||
|     "@babel/runtime": "7.28.4", | ||||
|     "@braintree/sanitize-url": "7.1.1", | ||||
|     "@codemirror/autocomplete": "6.19.1", | ||||
|     "@codemirror/commands": "6.10.0", | ||||
|     "@codemirror/autocomplete": "6.19.0", | ||||
|     "@codemirror/commands": "6.9.0", | ||||
|     "@codemirror/language": "6.11.3", | ||||
|     "@codemirror/legacy-modes": "6.5.2", | ||||
|     "@codemirror/search": "6.5.11", | ||||
| @@ -52,8 +52,8 @@ | ||||
|     "@fullcalendar/list": "6.1.19", | ||||
|     "@fullcalendar/luxon3": "6.1.19", | ||||
|     "@fullcalendar/timegrid": "6.1.19", | ||||
|     "@home-assistant/webawesome": "3.0.0-beta.6.ha.6", | ||||
|     "@lezer/highlight": "1.2.3", | ||||
|     "@home-assistant/webawesome": "3.0.0-beta.6.ha.5", | ||||
|     "@lezer/highlight": "1.2.2", | ||||
|     "@lit-labs/motion": "1.0.9", | ||||
|     "@lit-labs/observers": "2.0.6", | ||||
|     "@lit-labs/virtualizer": "2.1.1", | ||||
| @@ -148,16 +148,16 @@ | ||||
|     "xss": "1.0.15" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "7.28.5", | ||||
|     "@babel/core": "7.28.4", | ||||
|     "@babel/helper-define-polyfill-provider": "0.6.5", | ||||
|     "@babel/plugin-transform-runtime": "7.28.5", | ||||
|     "@babel/preset-env": "7.28.5", | ||||
|     "@babel/plugin-transform-runtime": "7.28.3", | ||||
|     "@babel/preset-env": "7.28.3", | ||||
|     "@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.4", | ||||
|     "@rsdoctor/rspack-plugin": "1.3.3", | ||||
|     "@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.6", | ||||
|     "@types/sortablejs": "1.15.9", | ||||
|     "@types/qrcode": "1.5.5", | ||||
|     "@types/sortablejs": "1.15.8", | ||||
|     "@types/tar": "6.1.13", | ||||
|     "@types/ua-parser-js": "0.7.39", | ||||
|     "@types/webspeechapi": "0.0.29", | ||||
|     "@vitest/coverage-v8": "4.0.3", | ||||
|     "@vitest/coverage-v8": "3.2.4", | ||||
|     "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.6", | ||||
|     "lint-staged": "16.2.4", | ||||
|     "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.2", | ||||
|     "typescript-eslint": "8.46.1", | ||||
|     "vite-tsconfig-paths": "5.1.4", | ||||
|     "vitest": "4.0.3", | ||||
|     "vitest": "3.2.4", | ||||
|     "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" | ||||
|   | ||||
| @@ -1,116 +0,0 @@ | ||||
| export interface SwipeGestureResult { | ||||
|   velocity: number; | ||||
|   delta: number; | ||||
|   isSwipe: boolean; | ||||
|   isDownwardSwipe: boolean; | ||||
| } | ||||
|  | ||||
| export interface SwipeGestureConfig { | ||||
|   velocitySwipeThreshold?: number; | ||||
|   movementTimeThreshold?: number; | ||||
| } | ||||
|  | ||||
| const VELOCITY_SWIPE_THRESHOLD = 0.5; // px/ms | ||||
| const MOVEMENT_TIME_THRESHOLD = 100; // ms | ||||
|  | ||||
| /** | ||||
|  * Recognizes swipe gestures and calculates velocity for touch interactions. | ||||
|  * Tracks touch movement and provides velocity-based and position-based gesture detection. | ||||
|  */ | ||||
| export class SwipeGestureRecognizer { | ||||
|   private _startY = 0; | ||||
|  | ||||
|   private _delta = 0; | ||||
|  | ||||
|   private _startTime = 0; | ||||
|  | ||||
|   private _lastY = 0; | ||||
|  | ||||
|   private _lastTime = 0; | ||||
|  | ||||
|   private _velocityThreshold: number; | ||||
|  | ||||
|   private _movementTimeThreshold: number; | ||||
|  | ||||
|   constructor(config: SwipeGestureConfig = {}) { | ||||
|     this._velocityThreshold = | ||||
|       config.velocitySwipeThreshold ?? VELOCITY_SWIPE_THRESHOLD; // px/ms | ||||
|     this._movementTimeThreshold = | ||||
|       config.movementTimeThreshold ?? MOVEMENT_TIME_THRESHOLD; // ms | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initialize gesture tracking with starting touch position | ||||
|    */ | ||||
|   public start(clientY: number): void { | ||||
|     const now = Date.now(); | ||||
|     this._startY = clientY; | ||||
|     this._startTime = now; | ||||
|     this._lastY = clientY; | ||||
|     this._lastTime = now; | ||||
|     this._delta = 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update gesture state during movement | ||||
|    * Returns the current delta (negative when dragging down) | ||||
|    */ | ||||
|   public move(clientY: number): number { | ||||
|     const now = Date.now(); | ||||
|     this._delta = this._startY - clientY; | ||||
|     this._lastY = clientY; | ||||
|     this._lastTime = now; | ||||
|     return this._delta; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Calculate final gesture result when touch ends | ||||
|    */ | ||||
|   public end(): SwipeGestureResult { | ||||
|     const velocity = this.getVelocity(); | ||||
|     const hasSignificantVelocity = Math.abs(velocity) > this._velocityThreshold; | ||||
|  | ||||
|     return { | ||||
|       velocity, | ||||
|       delta: this._delta, | ||||
|       isSwipe: hasSignificantVelocity, | ||||
|       isDownwardSwipe: velocity > 0, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get current drag delta (negative when dragging down) | ||||
|    */ | ||||
|   public getDelta(): number { | ||||
|     return this._delta; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Calculate velocity based on recent movement | ||||
|    * Returns 0 if no recent movement detected | ||||
|    * Positive velocity means downward swipe | ||||
|    */ | ||||
|   public getVelocity(): number { | ||||
|     const now = Date.now(); | ||||
|     const timeSinceLastMove = now - this._lastTime; | ||||
|  | ||||
|     // Only consider velocity if the last movement was recent | ||||
|     if (timeSinceLastMove >= this._movementTimeThreshold) { | ||||
|       return 0; | ||||
|     } | ||||
|  | ||||
|     const timeDelta = this._lastTime - this._startTime; | ||||
|     return timeDelta > 0 ? (this._lastY - this._startY) / timeDelta : 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Reset all tracking state | ||||
|    */ | ||||
|   public reset(): void { | ||||
|     this._startY = 0; | ||||
|     this._delta = 0; | ||||
|     this._startTime = 0; | ||||
|     this._lastY = 0; | ||||
|     this._lastTime = 0; | ||||
|   } | ||||
| } | ||||
| @@ -88,19 +88,9 @@ export class HaChartBase extends LitElement { | ||||
|  | ||||
|   private _lastTapTime?: number; | ||||
|  | ||||
|   private _shouldResizeChart = false; | ||||
|  | ||||
|   // @ts-ignore | ||||
|   private _resizeController = new ResizeController(this, { | ||||
|     callback: () => { | ||||
|       if (this.chart) { | ||||
|         if (!this.chart.getZr().animation.isFinished()) { | ||||
|           this._shouldResizeChart = true; | ||||
|         } else { | ||||
|           this.chart.resize(); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     callback: () => this.chart?.resize(), | ||||
|   }); | ||||
|  | ||||
|   private _loading = false; | ||||
| @@ -376,7 +366,6 @@ 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) { | ||||
| @@ -956,13 +945,6 @@ export class HaChartBase extends LitElement { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   private _handleChartRenderFinished = () => { | ||||
|     if (this._shouldResizeChart) { | ||||
|       this.chart?.resize(); | ||||
|       this._shouldResizeChart = false; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|   | ||||
| @@ -1 +0,0 @@ | ||||
| export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__"; | ||||
| @@ -147,7 +147,6 @@ class HaEntitiesPicker extends LitElement { | ||||
|           .createDomains=${this.createDomains} | ||||
|           .required=${this.required && !currentEntities.length} | ||||
|           @value-changed=${this._addEntity} | ||||
|           add-button | ||||
|         ></ha-entity-picker> | ||||
|       </div> | ||||
|     `; | ||||
|   | ||||
| @@ -113,9 +113,6 @@ 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 { | ||||
| @@ -284,7 +281,7 @@ export class HaEntityPicker extends LitElement { | ||||
|         .searchLabel=${this.searchLabel} | ||||
|         .notFoundLabel=${notFoundLabel} | ||||
|         .placeholder=${placeholder} | ||||
|         .value=${this.addButton ? undefined : this.value} | ||||
|         .value=${this.value} | ||||
|         .rowRenderer=${this._rowRenderer} | ||||
|         .getItems=${this._getItems} | ||||
|         .getAdditionalItems=${this._getAdditionalItems} | ||||
| @@ -292,9 +289,6 @@ 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,39 +1,23 @@ | ||||
| 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 { mdiDragHorizontalVariant } from "@mdi/js"; | ||||
| import type { HassEntity } from "home-assistant-js-websocket"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { LitElement, css, html, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { 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 "../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> | ||||
| `; | ||||
| import "../chips/ha-input-chip"; | ||||
| import "../chips/ha-chip-set"; | ||||
| import type { HaComboBox } from "../ha-combo-box"; | ||||
|  | ||||
| const HIDDEN_ATTRIBUTES = [ | ||||
|   "access_token", | ||||
| @@ -90,7 +74,7 @@ const HIDDEN_ATTRIBUTES = [ | ||||
| ]; | ||||
|  | ||||
| @customElement("ha-entity-state-content-picker") | ||||
| export class HaStateContentPicker extends LitElement { | ||||
| class HaEntityStatePicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: false }) public entityId?: string; | ||||
| @@ -111,28 +95,26 @@ export class HaStateContentPicker extends LitElement { | ||||
|  | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @query(".container", true) private _container?: HTMLDivElement; | ||||
|   @state() private _opened = false; | ||||
|  | ||||
|   @query("ha-combo-box", true) private _comboBox!: HaComboBox; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|   protected shouldUpdate(changedProps: PropertyValues) { | ||||
|     return !(!changedProps.has("_opened") && this._opened); | ||||
|   } | ||||
|  | ||||
|   private _editIndex?: number; | ||||
|  | ||||
|   private _options = memoizeOne( | ||||
|   private options = memoizeOne( | ||||
|     (entityId?: string, stateObj?: HassEntity, allowName?: boolean) => { | ||||
|       const domain = entityId ? computeDomain(entityId) : undefined; | ||||
|       return [ | ||||
|         { | ||||
|           primary: this.hass.localize( | ||||
|             "ui.components.state-content-picker.state" | ||||
|           ), | ||||
|           label: this.hass.localize("ui.components.state-content-picker.state"), | ||||
|           value: "state", | ||||
|         }, | ||||
|         ...(allowName | ||||
|           ? [ | ||||
|               { | ||||
|                 primary: this.hass.localize( | ||||
|                 label: this.hass.localize( | ||||
|                   "ui.components.state-content-picker.name" | ||||
|                 ), | ||||
|                 value: "name", | ||||
| @@ -140,13 +122,13 @@ export class HaStateContentPicker extends LitElement { | ||||
|             ] | ||||
|           : []), | ||||
|         { | ||||
|           primary: this.hass.localize( | ||||
|           label: this.hass.localize( | ||||
|             "ui.components.state-content-picker.last_changed" | ||||
|           ), | ||||
|           value: "last_changed", | ||||
|         }, | ||||
|         { | ||||
|           primary: this.hass.localize( | ||||
|           label: this.hass.localize( | ||||
|             "ui.components.state-content-picker.last_updated" | ||||
|           ), | ||||
|           value: "last_updated", | ||||
| @@ -155,7 +137,7 @@ export class HaStateContentPicker extends LitElement { | ||||
|           ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) => | ||||
|               STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content) | ||||
|             ).map((content) => ({ | ||||
|               primary: this.hass.localize( | ||||
|               label: this.hass.localize( | ||||
|                 `ui.components.state-content-picker.${content}` | ||||
|               ), | ||||
|               value: content, | ||||
| @@ -164,201 +146,108 @@ export class HaStateContentPicker 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 options = this.options(this.entityId, stateObj, this.allowName); | ||||
|     const optionItems = options.filter( | ||||
|       (option) => !this._value.includes(option.value) | ||||
|     ); | ||||
|  | ||||
|     return html` | ||||
|       ${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> | ||||
|       ${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} | ||||
|  | ||||
|         <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> | ||||
|       <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> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   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>) { | ||||
|     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; | ||||
|     } | ||||
|     this._opened = ev.detail.value; | ||||
|     this._comboBox.filteredItems = this._comboBox.items; | ||||
|   } | ||||
|  | ||||
|   private _filterSelectedOptions = ( | ||||
|     options: StateContentOption[], | ||||
|     current?: string | ||||
|   ) => { | ||||
|     const value = this._value; | ||||
|   private _filterChanged(ev?: CustomEvent): void { | ||||
|     this._filter = ev?.detail.value || ""; | ||||
|  | ||||
|     return options.filter( | ||||
|       (option) => !value.includes(option.value) || option.value === current | ||||
|     ); | ||||
|   }; | ||||
|     const filteredItems = this._comboBox.items?.filter((item) => { | ||||
|       const label = item.label || item.value; | ||||
|       return label.toLowerCase().includes(this._filter?.toLowerCase()); | ||||
|     }); | ||||
|  | ||||
|   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; | ||||
|     if (this._filter) { | ||||
|       filteredItems?.unshift({ label: this._filter, value: this._filter }); | ||||
|     } | ||||
|  | ||||
|     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; | ||||
|   } | ||||
|  | ||||
| @@ -371,40 +260,43 @@ export class HaStateContentPicker extends LitElement { | ||||
|     newValue.splice(newIndex, 0, element); | ||||
|     this._setValue(newValue); | ||||
|     await this.updateComplete; | ||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); | ||||
|     this._filterChanged(); | ||||
|   } | ||||
|  | ||||
|   private async _removeItem(ev) { | ||||
|     ev.stopPropagation(); | ||||
|     const value = [...this._value]; | ||||
|     const idx = parseInt(ev.target.dataset.idx, 10); | ||||
|     value.splice(idx, 1); | ||||
|     const value: string[] = [...this._value]; | ||||
|     value.splice(ev.target.idx, 1); | ||||
|     this._setValue(value); | ||||
|     await this.updateComplete; | ||||
|     this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>); | ||||
|     this._filterChanged(); | ||||
|   } | ||||
|  | ||||
|   private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void { | ||||
|   private _comboBoxValueChanged(ev: CustomEvent): void { | ||||
|     ev.stopPropagation(); | ||||
|     const value = ev.detail.value; | ||||
|     const newValue = ev.detail.value; | ||||
|  | ||||
|     if (this.disabled || value === "") { | ||||
|     if (this.disabled || newValue === "") { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const newValue = [...this._value]; | ||||
|     const currentValue = this._value; | ||||
|  | ||||
|     if (this._editIndex != null) { | ||||
|       newValue[this._editIndex] = value; | ||||
|     } else { | ||||
|       newValue.push(value); | ||||
|     if (currentValue.includes(newValue)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this._setValue(newValue); | ||||
|     setTimeout(() => { | ||||
|       this._filterChanged(); | ||||
|       this._comboBox.setInputValue(""); | ||||
|     }, 0); | ||||
|  | ||||
|     this._setValue([...currentValue, newValue]); | ||||
|   } | ||||
|  | ||||
|   private _setValue(value: string[]) { | ||||
|     const newValue = this._toValue(value); | ||||
|     const newValue = | ||||
|       value.length === 0 ? undefined : value.length === 1 ? value[0] : value; | ||||
|     this.value = newValue; | ||||
|     fireEvent(this, "value-changed", { | ||||
|       value: newValue, | ||||
| @@ -414,64 +306,10 @@ export class HaStateContentPicker 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: var(--ha-space-2) var(--ha-space-2); | ||||
|     } | ||||
|  | ||||
|     .invalid { | ||||
|       text-decoration: line-through; | ||||
|       padding: 8px 0; | ||||
|     } | ||||
|  | ||||
|     .sortable-fallback { | ||||
| @@ -491,6 +329,6 @@ export class HaStateContentPicker extends LitElement { | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-entity-state-content-picker": HaStateContentPicker; | ||||
|     "ha-entity-state-content-picker": HaEntityStatePicker; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import { customElement, property } from "lit/decorators"; | ||||
| import { keyed } from "lit/directives/keyed"; | ||||
| import { repeat } from "lit/directives/repeat"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import { ANY_STATE_VALUE } from "./const"; | ||||
| import { ensureArray } from "../../common/array/ensure-array"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import "./ha-entity-state-picker"; | ||||
| @@ -58,7 +57,6 @@ export class HaEntityStatesPicker extends LitElement { | ||||
|  | ||||
|     const value = this.value || []; | ||||
|     const hide = [...(this.hideStates || []), ...value]; | ||||
|     const hideValue = value.includes(ANY_STATE_VALUE); | ||||
|  | ||||
|     return html` | ||||
|       ${repeat( | ||||
| @@ -86,7 +84,7 @@ export class HaEntityStatesPicker extends LitElement { | ||||
|         ` | ||||
|       )} | ||||
|       <div> | ||||
|         ${(this.disabled && value.length) || hideValue | ||||
|         ${this.disabled && value.length | ||||
|           ? nothing | ||||
|           : keyed( | ||||
|               value.length, | ||||
|   | ||||
| @@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement { | ||||
|         </span> | ||||
|         <ha-switch | ||||
|           @change=${this._handleRowClick} | ||||
|           .checked=${!!baseEnabled} | ||||
|           .checked=${baseEnabled} | ||||
|           .preference=${"base"} | ||||
|           .disabled=${loading} | ||||
|           name="base" | ||||
| @@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement { | ||||
|               <ha-switch | ||||
|                 .id="switch-${preference}" | ||||
|                 @change=${this._handleRowClick} | ||||
|                 .checked=${!!this.analytics?.preferences[preference]} | ||||
|                 .checked=${this.analytics?.preferences[preference]} | ||||
|                 .preference=${preference} | ||||
|                 name=${preference} | ||||
|               > | ||||
| @@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement { | ||||
|         </span> | ||||
|         <ha-switch | ||||
|           @change=${this._handleRowClick} | ||||
|           .checked=${!!this.analytics?.preferences.diagnostics} | ||||
|           .checked=${this.analytics?.preferences.diagnostics} | ||||
|           .preference=${"diagnostics"} | ||||
|           .disabled=${loading} | ||||
|           name="diagnostics" | ||||
|   | ||||
| @@ -118,7 +118,7 @@ export class HaAutomationRow extends LitElement { | ||||
|     } | ||||
|     .row { | ||||
|       display: flex; | ||||
|       padding: var(--ha-space-0) var(--ha-space-2); | ||||
|       padding: 0 8px; | ||||
|       min-height: 48px; | ||||
|       align-items: center; | ||||
|       cursor: pointer; | ||||
| @@ -134,12 +134,12 @@ export class HaAutomationRow extends LitElement { | ||||
|     .expand-button { | ||||
|       transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); | ||||
|       color: var(--ha-color-on-neutral-quiet); | ||||
|       margin-left: calc(var(--ha-space-2) * -1); | ||||
|       margin-left: -8px; | ||||
|     } | ||||
|     :host([building-block]) .leading-icon-wrapper { | ||||
|       background-color: var(--ha-color-fill-neutral-loud-resting); | ||||
|       border-radius: var(--ha-border-radius-md); | ||||
|       padding: var(--ha-space-1); | ||||
|       padding: 4px; | ||||
|       display: flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
| @@ -149,7 +149,7 @@ export class HaAutomationRow extends LitElement { | ||||
|       color: var(--ha-color-on-neutral-quiet); | ||||
|     } | ||||
|     :host([building-block]) ::slotted([slot="leading-icon"]) { | ||||
|       --mdc-icon-size: var(--ha-space-5); | ||||
|       --mdc-icon-size: 20px; | ||||
|       color: var(--white-color); | ||||
|       transform: rotate(-45deg); | ||||
|     } | ||||
| @@ -170,7 +170,7 @@ export class HaAutomationRow extends LitElement { | ||||
|     ::slotted([slot="header"]) { | ||||
|       flex: 1; | ||||
|       overflow-wrap: anywhere; | ||||
|       margin: var(--ha-space-0) var(--ha-space-3); | ||||
|       margin: 0 12px; | ||||
|     } | ||||
|     :host([sort-selected]) .row { | ||||
|       outline: solid; | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| import "@home-assistant/webawesome/dist/components/drawer/drawer"; | ||||
| import { css, html, LitElement, type PropertyValues } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
|  | ||||
| export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; | ||||
|  | ||||
| @@ -15,12 +13,6 @@ export class HaBottomSheet extends LitElement { | ||||
|  | ||||
|   @state() private _drawerOpen = false; | ||||
|  | ||||
|   @query("#drawer") private _drawer!: HTMLElement; | ||||
|  | ||||
|   private _gestureRecognizer = new SwipeGestureRecognizer(); | ||||
|  | ||||
|   private _isDragging = false; | ||||
|  | ||||
|   private _handleAfterHide() { | ||||
|     this.open = false; | ||||
|     const ev = new Event("closed", { | ||||
| @@ -40,186 +32,54 @@ export class HaBottomSheet extends LitElement { | ||||
|   render() { | ||||
|     return html` | ||||
|       <wa-drawer | ||||
|         id="drawer" | ||||
|         placement="bottom" | ||||
|         .open=${this._drawerOpen} | ||||
|         @wa-after-hide=${this._handleAfterHide} | ||||
|         without-header | ||||
|         @touchstart=${this._handleTouchStart} | ||||
|       > | ||||
|         <slot name="header"></slot> | ||||
|         <div id="body" class="body ha-scrollbar"> | ||||
|           <slot></slot> | ||||
|         </div> | ||||
|         <slot></slot> | ||||
|       </wa-drawer> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _handleTouchStart = (ev: TouchEvent) => { | ||||
|     // Check if any element inside drawer in the composed path has scrollTop > 0 | ||||
|     for (const path of ev.composedPath()) { | ||||
|       const el = path as HTMLElement; | ||||
|       if (el === this._drawer) { | ||||
|         break; | ||||
|       } | ||||
|       if (el.scrollTop > 0) { | ||||
|         return; | ||||
|       } | ||||
|   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) | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     this._startResizing(ev.touches[0].clientY); | ||||
|   }; | ||||
|  | ||||
|   private _startResizing(clientY: number) { | ||||
|     // register event listeners for drag handling | ||||
|     document.addEventListener("touchmove", this._handleTouchMove, { | ||||
|       passive: false, | ||||
|     }); | ||||
|     document.addEventListener("touchend", this._handleTouchEnd); | ||||
|     document.addEventListener("touchcancel", this._handleTouchEnd); | ||||
|  | ||||
|     this._gestureRecognizer.start(clientY); | ||||
|   } | ||||
|  | ||||
|   private _handleTouchMove = (ev: TouchEvent) => { | ||||
|     const currentY = ev.touches[0].clientY; | ||||
|     const delta = this._gestureRecognizer.move(currentY); | ||||
|  | ||||
|     if (delta < 0) { | ||||
|       ev.preventDefault(); | ||||
|       this._isDragging = true; | ||||
|       requestAnimationFrame(() => { | ||||
|         if (this._isDragging) { | ||||
|           this.style.setProperty( | ||||
|             "--dialog-transform", | ||||
|             `translateY(${delta * -1}px)` | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|     :host([flexcontent]) wa-drawer::part(body) { | ||||
|       display: flex; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _animateSnapBack() { | ||||
|     // Add transition for smooth animation | ||||
|     this.style.setProperty( | ||||
|       "--dialog-transition", | ||||
|       `transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease-out` | ||||
|     ); | ||||
|  | ||||
|     // Reset transform to snap back | ||||
|     this.style.removeProperty("--dialog-transform"); | ||||
|  | ||||
|     // Remove transition after animation completes | ||||
|     setTimeout(() => { | ||||
|       this.style.removeProperty("--dialog-transition"); | ||||
|     }, BOTTOM_SHEET_ANIMATION_DURATION_MS); | ||||
|   } | ||||
|  | ||||
|   private _handleTouchEnd = () => { | ||||
|     this._unregisterResizeHandlers(); | ||||
|  | ||||
|     this._isDragging = false; | ||||
|  | ||||
|     const result = this._gestureRecognizer.end(); | ||||
|  | ||||
|     // If velocity exceeds threshold, use velocity direction to determine action | ||||
|     if (result.isSwipe) { | ||||
|       if (result.isDownwardSwipe) { | ||||
|         // Downward swipe - close the bottom sheet | ||||
|         this._drawerOpen = false; | ||||
|       } else { | ||||
|         // Upward swipe - keep open and animate back | ||||
|         this._animateSnapBack(); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // If velocity is below threshold, use position-based logic | ||||
|     // Get the drawer height to calculate 50% threshold | ||||
|     const drawerBody = this._drawer.shadowRoot?.querySelector( | ||||
|       '[part="body"]' | ||||
|     ) as HTMLElement; | ||||
|     const drawerHeight = drawerBody?.offsetHeight || 0; | ||||
|  | ||||
|     // delta is negative when dragging down | ||||
|     // Close if dragged down past 50% of the drawer height | ||||
|     if ( | ||||
|       drawerHeight > 0 && | ||||
|       result.delta < 0 && | ||||
|       Math.abs(result.delta) > drawerHeight * 0.5 | ||||
|     ) { | ||||
|       this._drawerOpen = false; | ||||
|     } else { | ||||
|       this._animateSnapBack(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _unregisterResizeHandlers = () => { | ||||
|     document.removeEventListener("touchmove", this._handleTouchMove); | ||||
|     document.removeEventListener("touchend", this._handleTouchEnd); | ||||
|     document.removeEventListener("touchcancel", this._handleTouchEnd); | ||||
|   }; | ||||
|  | ||||
|   disconnectedCallback() { | ||||
|     super.disconnectedCallback(); | ||||
|     this._unregisterResizeHandlers(); | ||||
|     this._isDragging = false; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       wa-drawer { | ||||
|         --wa-color-surface-raised: transparent; | ||||
|         --spacing: 0; | ||||
|         --size: var(--ha-bottom-sheet-height, auto); | ||||
|         --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; | ||||
|         --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; | ||||
|       } | ||||
|       wa-drawer::part(dialog) { | ||||
|         max-height: var(--ha-bottom-sheet-max-height, 90vh); | ||||
|         align-items: center; | ||||
|         transform: var(--dialog-transform); | ||||
|         transition: var(--dialog-transition); | ||||
|       } | ||||
|       wa-drawer::part(body) { | ||||
|         max-width: var(--ha-bottom-sheet-max-width); | ||||
|         width: 100%; | ||||
|         border-top-left-radius: var( | ||||
|           --ha-bottom-sheet-border-radius, | ||||
|           var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||
|         ); | ||||
|         border-top-right-radius: var( | ||||
|           --ha-bottom-sheet-border-radius, | ||||
|           var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) | ||||
|         ); | ||||
|         background-color: var( | ||||
|           --ha-bottom-sheet-surface-background, | ||||
|           var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), | ||||
|         ); | ||||
|         padding: var( | ||||
|           --ha-bottom-sheet-padding, | ||||
|           0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) | ||||
|             var(--safe-area-inset-left) | ||||
|         ); | ||||
|       } | ||||
|       :host([flexcontent]) wa-drawer::part(body) { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
|       :host([flexcontent]) .body { | ||||
|         flex: 1; | ||||
|         max-width: 100%; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         padding: var( | ||||
|           --ha-bottom-sheet-padding, | ||||
|           0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) | ||||
|             var(--safe-area-inset-left) | ||||
|         ); | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -31,9 +31,6 @@ 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" | ||||
| @@ -41,13 +38,6 @@ 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"> | ||||
| @@ -56,9 +46,7 @@ export class HaButtonToggleGroup extends LitElement { | ||||
|             html`<ha-button | ||||
|               iconTag="ha-svg-icon" | ||||
|               class="icon" | ||||
|               .variant=${this.active !== button.value || !this.activeVariant | ||||
|                 ? this.variant | ||||
|                 : this.activeVariant} | ||||
|               .variant=${this.variant} | ||||
|               .size=${this.size} | ||||
|               .value=${button.value} | ||||
|               @click=${this._handleClick} | ||||
| @@ -90,19 +78,6 @@ 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; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -44,26 +44,26 @@ export class HaCard extends LitElement { | ||||
|       font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl)); | ||||
|       letter-spacing: -0.012em; | ||||
|       line-height: var(--ha-line-height-expanded); | ||||
|       padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4); | ||||
|       padding: 12px 16px 16px; | ||||
|       display: block; | ||||
|       margin-block-start: var(--ha-space-0); | ||||
|       margin-block-end: var(--ha-space-0); | ||||
|       margin-block-start: 0px; | ||||
|       margin-block-end: 0px; | ||||
|       font-weight: var(--ha-font-weight-normal); | ||||
|     } | ||||
|  | ||||
|     :host ::slotted(.card-content:not(:first-child)), | ||||
|     slot:not(:first-child)::slotted(.card-content) { | ||||
|       padding-top: var(--ha-space-0); | ||||
|       margin-top: calc(var(--ha-space-2) * -1); | ||||
|       padding-top: 0px; | ||||
|       margin-top: -8px; | ||||
|     } | ||||
|  | ||||
|     :host ::slotted(.card-content) { | ||||
|       padding: var(--ha-space-4); | ||||
|       padding: 16px; | ||||
|     } | ||||
|  | ||||
|     :host ::slotted(.card-actions) { | ||||
|       border-top: 1px solid var(--divider-color, #e8e8e8); | ||||
|       padding: var(--ha-space-2); | ||||
|       padding: 8px; | ||||
|     } | ||||
|   `; | ||||
|  | ||||
|   | ||||
| @@ -6,9 +6,6 @@ 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: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|           padding: 0 8px; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|           padding: 0 8px; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|           padding: 0 8px; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|           padding: 0 8px; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) { | ||||
|         } | ||||
|         search-input-outlined { | ||||
|           display: block; | ||||
|           padding: var(--ha-space-1) var(--ha-space-2) 0; | ||||
|           padding: 0 8px; | ||||
|         } | ||||
|       `, | ||||
|     ]; | ||||
|   | ||||
| @@ -1,14 +1,10 @@ | ||||
| import "@home-assistant/webawesome/dist/components/popover/popover"; | ||||
| import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; | ||||
| import { mdiPlaylistPlus } from "@mdi/js"; | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; | ||||
| 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"; | ||||
| @@ -19,12 +15,12 @@ import type { | ||||
|   PickerComboBoxSearchFn, | ||||
| } from "./ha-picker-combo-box"; | ||||
| import "./ha-picker-field"; | ||||
| import type { PickerValueRenderer } from "./ha-picker-field"; | ||||
| import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field"; | ||||
| import "./ha-svg-icon"; | ||||
|  | ||||
| @customElement("ha-generic-picker") | ||||
| export class HaGenericPicker extends LitElement { | ||||
|   @property({ attribute: false }) public hass?: HomeAssistant; | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   // eslint-disable-next-line lit/no-native-attributes | ||||
|   @property({ type: Boolean }) public autofocus = false; | ||||
| @@ -57,7 +53,7 @@ export class HaGenericPicker extends LitElement { | ||||
|   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; | ||||
|   public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public valueRenderer?: PickerValueRenderer; | ||||
| @@ -68,142 +64,59 @@ export class HaGenericPicker extends LitElement { | ||||
|   @property({ attribute: "not-found-label", type: String }) | ||||
|   public notFoundLabel?: string; | ||||
|  | ||||
|   @property({ attribute: "popover-placement" }) | ||||
|   public popoverPlacement: | ||||
|     | "bottom" | ||||
|     | "top" | ||||
|     | "left" | ||||
|     | "right" | ||||
|     | "top-start" | ||||
|     | "top-end" | ||||
|     | "right-start" | ||||
|     | "right-end" | ||||
|     | "bottom-start" | ||||
|     | "bottom-end" | ||||
|     | "left-start" | ||||
|     | "left-end" = "bottom-start"; | ||||
|  | ||||
|   /** If set picker shows an add button instead of textbox when value isn't set */ | ||||
|   @property({ attribute: "add-button-label" }) public addButtonLabel?: string; | ||||
|  | ||||
|   @query(".container") private _containerElement?: HTMLDivElement; | ||||
|   @query("ha-picker-field") private _field?: HaPickerField; | ||||
|  | ||||
|   @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"> | ||||
|         <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) | ||||
|         ${!this._opened | ||||
|           ? html` | ||||
|               <wa-popover | ||||
|                 .open=${this._pickerWrapperOpen} | ||||
|                 style="--body-width: ${this._popoverWidth}px;" | ||||
|                 without-arrow | ||||
|                 distance="-4" | ||||
|                 .placement=${this.popoverPlacement} | ||||
|                 for="picker" | ||||
|                 auto-size="vertical" | ||||
|                 auto-size-padding="16" | ||||
|                 @wa-after-show=${this._dialogOpened} | ||||
|                 @wa-after-hide=${this._hidePicker} | ||||
|                 trap-focus | ||||
|                 role="dialog" | ||||
|                 aria-modal="true" | ||||
|                 aria-label=${this.label || "Select option"} | ||||
|               <ha-picker-field | ||||
|                 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} | ||||
|               > | ||||
|                 ${this._renderComboBox()} | ||||
|               </wa-popover> | ||||
|               </ha-picker-field> | ||||
|             ` | ||||
|           : this._pickerWrapperOpen || this._opened | ||||
|             ? html`<ha-bottom-sheet | ||||
|                 flexcontent | ||||
|                 .open=${this._pickerWrapperOpen} | ||||
|                 @wa-after-show=${this._dialogOpened} | ||||
|                 @closed=${this._hidePicker} | ||||
|                 role="dialog" | ||||
|                 aria-modal="true" | ||||
|                 aria-label=${this.label || "Select option"} | ||||
|               > | ||||
|                 ${this._renderComboBox(true)} | ||||
|               </ha-bottom-sheet>` | ||||
|             : nothing} | ||||
|           : 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> | ||||
|             `} | ||||
|       </div> | ||||
|       ${this._renderHelper()} | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderComboBox(dialogMode = false) { | ||||
|     if (!this._opened) { | ||||
|       return nothing; | ||||
|     } | ||||
|     return html` | ||||
|       <ha-picker-combo-box | ||||
|         .hass=${this.hass} | ||||
|         .allowCustomValue=${this.allowCustomValue} | ||||
|         .label=${this.searchLabel ?? | ||||
|         (this.hass?.localize("ui.common.search") || "Search")} | ||||
|         .value=${this.value} | ||||
|         @value-changed=${this._valueChanged} | ||||
|         .rowRenderer=${this.rowRenderer} | ||||
|         .notFoundLabel=${this.notFoundLabel} | ||||
|         .getItems=${this.getItems} | ||||
|         .getAdditionalItems=${this.getAdditionalItems} | ||||
|         .searchFn=${this.searchFn} | ||||
|         .mode=${dialogMode ? "dialog" : "popover"} | ||||
|       ></ha-picker-combo-box> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _renderHelper() { | ||||
|     return this.helper | ||||
|       ? html`<ha-input-helper-text .disabled=${this.disabled} | ||||
| @@ -212,33 +125,13 @@ 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; | ||||
|     } | ||||
|     this._pickerWrapperOpen = false; | ||||
|     this._newValue = value; | ||||
|     fireEvent(this, "value-changed", { value }); | ||||
|   } | ||||
|  | ||||
|   private _clear(e) { | ||||
| @@ -251,44 +144,24 @@ export class HaGenericPicker extends LitElement { | ||||
|     fireEvent(this, "value-changed", { value }); | ||||
|   } | ||||
|  | ||||
|   public async open(ev?: Event) { | ||||
|     ev?.stopPropagation(); | ||||
|   public async open() { | ||||
|     if (this.disabled) { | ||||
|       return; | ||||
|     } | ||||
|     this._openedNarrow = this._narrow; | ||||
|     this._popoverWidth = this._containerElement?.offsetWidth || 250; | ||||
|     this._pickerWrapperOpen = true; | ||||
|     this._unsubscribeTinyKeys = tinykeys(this, { | ||||
|       Escape: this._handleEscClose, | ||||
|     }); | ||||
|     this._opened = true; | ||||
|     await this.updateComplete; | ||||
|     this._comboBox?.focus(); | ||||
|     this._comboBox?.open(); | ||||
|   } | ||||
|  | ||||
|   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 async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { | ||||
|     const opened = ev.detail.value; | ||||
|     if (this._opened && !opened) { | ||||
|       this._opened = false; | ||||
|       await this.updateComplete; | ||||
|       this._field?.focus(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _handleEscClose = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|   }; | ||||
|   } | ||||
|  | ||||
|   static get styles(): CSSResultGroup { | ||||
|     return [ | ||||
| @@ -308,44 +181,6 @@ 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,13 +2,7 @@ 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, | ||||
|   queryAssignedElements, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import type { LabelRegistryEntry } from "../data/label_registry"; | ||||
| @@ -90,9 +84,6 @@ 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() { | ||||
| @@ -220,14 +211,12 @@ 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} | ||||
| @@ -235,7 +224,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { | ||||
|         .valueRenderer=${valueRenderer} | ||||
|         @value-changed=${this._valueChanged} | ||||
|       > | ||||
|         <slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot> | ||||
|       </ha-generic-picker> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| 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"; | ||||
| @@ -124,6 +123,36 @@ 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} | ||||
| @@ -133,47 +162,6 @@ 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> | ||||
|     `; | ||||
|   } | ||||
| @@ -215,25 +203,9 @@ 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,58 +1,56 @@ | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { customElement, property, query, state } from "lit/decorators"; | ||||
| import memoizeOne from "memoize-one"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { stopPropagation } from "../common/dom/stop_propagation"; | ||||
| import { formatLanguageCode } from "../common/language/format_language"; | ||||
| import { caseInsensitiveStringCompare } from "../common/string/compare"; | ||||
| import type { FrontendLocaleData } from "../data/translation"; | ||||
| import { translationMetadata } from "../resources/translations-metadata"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||
| import "./ha-generic-picker"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import "./ha-list-item"; | ||||
| import type { PickerComboBoxItem } from "./ha-picker-combo-box"; | ||||
| import "./ha-select"; | ||||
| import type { HaSelect } from "./ha-select"; | ||||
|  | ||||
| export const getLanguageOptions = ( | ||||
|   languages: string[], | ||||
|   nativeName: boolean, | ||||
|   noSort: boolean, | ||||
|   locale?: FrontendLocaleData | ||||
| ): PickerComboBoxItem[] => { | ||||
|   let options: PickerComboBoxItem[] = []; | ||||
| ) => { | ||||
|   let options: { label: string; value: string }[] = []; | ||||
|  | ||||
|   if (nativeName) { | ||||
|     const translations = translationMetadata.translations; | ||||
|     options = languages.map((lang) => { | ||||
|       let primary = translations[lang]?.nativeName; | ||||
|       if (!primary) { | ||||
|       let label = translations[lang]?.nativeName; | ||||
|       if (!label) { | ||||
|         try { | ||||
|           // this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user | ||||
|           primary = new Intl.DisplayNames(lang, { | ||||
|           label = new Intl.DisplayNames(lang, { | ||||
|             type: "language", | ||||
|             fallback: "code", | ||||
|           }).of(lang)!; | ||||
|         } catch (_err) { | ||||
|           primary = lang; | ||||
|           label = lang; | ||||
|         } | ||||
|       } | ||||
|       return { | ||||
|         id: lang, | ||||
|         primary, | ||||
|         search_labels: [primary], | ||||
|         value: lang, | ||||
|         label, | ||||
|       }; | ||||
|     }); | ||||
|   } else if (locale) { | ||||
|     options = languages.map((lang) => ({ | ||||
|       id: lang, | ||||
|       primary: formatLanguageCode(lang, locale), | ||||
|       search_labels: [formatLanguageCode(lang, locale)], | ||||
|       value: lang, | ||||
|       label: formatLanguageCode(lang, locale), | ||||
|     })); | ||||
|   } | ||||
|  | ||||
|   if (!noSort && locale) { | ||||
|     options.sort((a, b) => | ||||
|       caseInsensitiveStringCompare(a.primary, b.primary, locale.language) | ||||
|       caseInsensitiveStringCompare(a.label, b.label, locale.language) | ||||
|     ); | ||||
|   } | ||||
|   return options; | ||||
| @@ -82,69 +80,115 @@ export class HaLanguagePicker extends LitElement { | ||||
|  | ||||
|   @state() _defaultLanguages: string[] = []; | ||||
|  | ||||
|   @query("ha-select") private _select!: HaSelect; | ||||
|  | ||||
|   protected firstUpdated(changedProps: PropertyValues) { | ||||
|     super.firstUpdated(changedProps); | ||||
|     this._computeDefaultLanguageOptions(); | ||||
|   } | ||||
|  | ||||
|   protected updated(changedProperties: PropertyValues) { | ||||
|     super.updated(changedProperties); | ||||
|  | ||||
|     const localeChanged = | ||||
|       changedProperties.has("hass") && | ||||
|       this.hass && | ||||
|       changedProperties.get("hass") && | ||||
|       changedProperties.get("hass").locale.language !== | ||||
|         this.hass.locale.language; | ||||
|     if ( | ||||
|       changedProperties.has("languages") || | ||||
|       changedProperties.has("value") || | ||||
|       localeChanged | ||||
|     ) { | ||||
|       this._select.layoutOptions(); | ||||
|       if (!this.disabled && this._select.value !== this.value) { | ||||
|         fireEvent(this, "value-changed", { value: this._select.value }); | ||||
|       } | ||||
|       if (!this.value) { | ||||
|         return; | ||||
|       } | ||||
|       const languageOptions = this._getLanguagesOptions( | ||||
|         this.languages ?? this._defaultLanguages, | ||||
|         this.nativeName, | ||||
|         this.noSort, | ||||
|         this.hass?.locale | ||||
|       ); | ||||
|       const selectedItemIndex = languageOptions.findIndex( | ||||
|         (option) => option.value === this.value | ||||
|       ); | ||||
|       if (selectedItemIndex === -1) { | ||||
|         this.value = undefined; | ||||
|       } | ||||
|       if (localeChanged) { | ||||
|         this._select.select(selectedItemIndex); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _getLanguagesOptions = memoizeOne(getLanguageOptions); | ||||
|  | ||||
|   private _computeDefaultLanguageOptions() { | ||||
|     this._defaultLanguages = Object.keys(translationMetadata.translations); | ||||
|   } | ||||
|  | ||||
|   private _getItems = () => | ||||
|     this._getLanguagesOptions( | ||||
|   protected render() { | ||||
|     const languageOptions = this._getLanguagesOptions( | ||||
|       this.languages ?? this._defaultLanguages, | ||||
|       this.nativeName, | ||||
|       this.noSort, | ||||
|       this.hass?.locale | ||||
|     ); | ||||
|  | ||||
|   private _valueRenderer = (value) => { | ||||
|     const language = this._getItems().find( | ||||
|       (lang) => lang.id === value | ||||
|     )?.primary; | ||||
|     return html`<span slot="headline">${language ?? value}</span> `; | ||||
|   }; | ||||
|  | ||||
|   protected render() { | ||||
|     const value = | ||||
|       this.value ?? | ||||
|       (this.required && !this.disabled ? this._getItems()[0].id : this.value); | ||||
|       (this.required && !this.disabled | ||||
|         ? languageOptions[0]?.value | ||||
|         : this.value); | ||||
|  | ||||
|     return html` | ||||
|       <ha-generic-picker | ||||
|         .hass=${this.hass} | ||||
|         .autofocus=${this.autofocus} | ||||
|         popover-placement="bottom-end" | ||||
|         .notFoundLabel=${this.hass?.localize( | ||||
|           "ui.components.language-picker.no_match" | ||||
|         )} | ||||
|         .placeholder=${this.label ?? | ||||
|       <ha-select | ||||
|         .label=${this.label ?? | ||||
|         (this.hass?.localize("ui.components.language-picker.language") || | ||||
|           "Language")} | ||||
|         .value=${value} | ||||
|         .valueRenderer=${this._valueRenderer} | ||||
|         .value=${value || ""} | ||||
|         .required=${this.required} | ||||
|         .disabled=${this.disabled} | ||||
|         .getItems=${this._getItems} | ||||
|         @value-changed=${this._changed} | ||||
|         hide-clear-icon | ||||
|       ></ha-generic-picker> | ||||
|         @selected=${this._changed} | ||||
|         @closed=${stopPropagation} | ||||
|         fixedMenuPosition | ||||
|         naturalMenuWidth | ||||
|         .inlineArrow=${this.inlineArrow} | ||||
|       > | ||||
|         ${languageOptions.length === 0 | ||||
|           ? html`<ha-list-item value="" | ||||
|               >${this.hass?.localize( | ||||
|                 "ui.components.language-picker.no_languages" | ||||
|               ) || "No languages"}</ha-list-item | ||||
|             >` | ||||
|           : languageOptions.map( | ||||
|               (option) => html` | ||||
|                 <ha-list-item .value=${option.value} | ||||
|                   >${option.label}</ha-list-item | ||||
|                 > | ||||
|               ` | ||||
|             )} | ||||
|       </ha-select> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     ha-generic-picker { | ||||
|     ha-select { | ||||
|       width: 100%; | ||||
|       min-width: 200px; | ||||
|       display: block; | ||||
|     } | ||||
|   `; | ||||
|  | ||||
|   private _changed(ev: ValueChangedEvent<string>): void { | ||||
|     ev.stopPropagation(); | ||||
|     this.value = ev.detail.value; | ||||
|   private _changed(ev): void { | ||||
|     const target = ev.target as HaSelect; | ||||
|     if (this.disabled || target.value === "" || target.value === this.value) { | ||||
|       return; | ||||
|     } | ||||
|     this.value = target.value; | ||||
|     fireEvent(this, "value-changed", { value: this.value }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -50,7 +50,7 @@ export class HaMarkdown extends LitElement { | ||||
|     } | ||||
|     ha-alert { | ||||
|       display: block; | ||||
|       margin: var(--ha-space-1) 0; | ||||
|       margin: 4px 0; | ||||
|     } | ||||
|     a { | ||||
|       color: var(--primary-color); | ||||
| @@ -75,7 +75,7 @@ export class HaMarkdown extends LitElement { | ||||
|       padding: 0; | ||||
|     } | ||||
|     pre { | ||||
|       padding: var(--ha-space-4); | ||||
|       padding: 16px; | ||||
|       overflow: auto; | ||||
|       line-height: var(--ha-line-height-condensed); | ||||
|       font-family: var(--ha-font-family-code); | ||||
| @@ -95,7 +95,7 @@ export class HaMarkdown extends LitElement { | ||||
|     hr { | ||||
|       border-color: var(--divider-color); | ||||
|       border-bottom: none; | ||||
|       margin: var(--ha-space-4) 0; | ||||
|       margin: 16px 0; | ||||
|     } | ||||
|   ` as CSSResultGroup; | ||||
| } | ||||
|   | ||||
| @@ -1,28 +1,19 @@ | ||||
| import type { LitVirtualizer } from "@lit-labs/virtualizer"; | ||||
| import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; | ||||
| import { mdiMagnify } from "@mdi/js"; | ||||
| import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; | ||||
| import Fuse from "fuse.js"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { | ||||
|   customElement, | ||||
|   eventOptions, | ||||
|   property, | ||||
|   query, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import type { PropertyValues, TemplateResult } from "lit"; | ||||
| import { html, LitElement, nothing } from "lit"; | ||||
| import { customElement, 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 { haStyleScrollbar } from "../resources/styles"; | ||||
| import { loadVirtualizer } from "../resources/virtualizer"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import type { HomeAssistant, ValueChangedEvent } from "../types"; | ||||
| import "./ha-combo-box"; | ||||
| import type { HaComboBox } from "./ha-combo-box"; | ||||
| import "./ha-combo-box-item"; | ||||
| import "./ha-icon"; | ||||
| import "./ha-textfield"; | ||||
| import type { HaTextField } from "./ha-textfield"; | ||||
|  | ||||
| export interface PickerComboBoxItem { | ||||
|   id: string; | ||||
| @@ -42,13 +33,10 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem { | ||||
|  | ||||
| const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___"; | ||||
|  | ||||
| const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( | ||||
| const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = ( | ||||
|   item | ||||
| ) => html` | ||||
|   <ha-combo-box-item | ||||
|     .type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"} | ||||
|     compact | ||||
|   > | ||||
|   <ha-combo-box-item type="button" compact> | ||||
|     ${item.icon | ||||
|       ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` | ||||
|       : item.icon_path | ||||
| @@ -69,7 +57,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = ( | ||||
|  | ||||
| @customElement("ha-picker-combo-box") | ||||
| export class HaPickerComboBox extends LitElement { | ||||
|   @property({ attribute: false }) public hass?: HomeAssistant; | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   // eslint-disable-next-line lit/no-native-attributes | ||||
|   @property({ type: Boolean }) public autofocus = false; | ||||
| @@ -85,7 +73,7 @@ export class HaPickerComboBox extends LitElement { | ||||
|  | ||||
|   @property() public value?: string; | ||||
|  | ||||
|   @state() private _listScrolled = false; | ||||
|   @property() public helper?: string; | ||||
|  | ||||
|   @property({ attribute: false, type: Array }) | ||||
|   public getItems?: () => PickerComboBoxItem[]; | ||||
| @@ -94,7 +82,10 @@ export class HaPickerComboBox extends LitElement { | ||||
|   public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; | ||||
|  | ||||
|   @property({ attribute: false }) | ||||
|   public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; | ||||
|   public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>; | ||||
|  | ||||
|   @property({ attribute: "hide-clear-icon", type: Boolean }) | ||||
|   public hideClearIcon = false; | ||||
|  | ||||
|   @property({ attribute: "not-found-label", type: String }) | ||||
|   public notFoundLabel?: string; | ||||
| @@ -102,77 +93,33 @@ export class HaPickerComboBox extends LitElement { | ||||
|   @property({ attribute: false }) | ||||
|   public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; | ||||
|  | ||||
|   @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; | ||||
|   @state() private _opened = false; | ||||
|  | ||||
|   @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; | ||||
|   @query("ha-combo-box", true) public comboBox!: HaComboBox; | ||||
|  | ||||
|   @query("ha-textfield") private _searchFieldElement?: HaTextField; | ||||
|  | ||||
|   @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 open() { | ||||
|     await this.updateComplete; | ||||
|     await this.comboBox?.open(); | ||||
|   } | ||||
|  | ||||
|   public willUpdate() { | ||||
|     if (!this.hasUpdated) { | ||||
|       loadVirtualizer(); | ||||
|       this._allItems = this._getItems(); | ||||
|       this._items = this._allItems; | ||||
|     } | ||||
|   public async focus() { | ||||
|     await this.updateComplete; | ||||
|     await this.comboBox?.focus(); | ||||
|   } | ||||
|  | ||||
|   disconnectedCallback() { | ||||
|     super.disconnectedCallback(); | ||||
|     this._removeKeyboardShortcuts?.(); | ||||
|   } | ||||
|   private _initialItems = false; | ||||
|  | ||||
|   protected render() { | ||||
|     return html`<ha-textfield | ||||
|         .label=${this.label ?? | ||||
|         this.hass?.localize("ui.common.search") ?? | ||||
|         "Search"} | ||||
|         @input=${this._filterChanged} | ||||
|       ></ha-textfield> | ||||
|       <lit-virtualizer | ||||
|         @scroll=${this._onScrollList} | ||||
|         tabindex="0" | ||||
|         scroller | ||||
|         .items=${this._items} | ||||
|         .renderItem=${this._renderItem} | ||||
|         style="min-height: 36px;" | ||||
|         class=${this._listScrolled ? "scrolled" : ""} | ||||
|         @focus=${this._focusList} | ||||
|       > | ||||
|       </lit-virtualizer> `; | ||||
|   } | ||||
|   private _items: PickerComboBoxItemWithLabel[] = []; | ||||
|  | ||||
|   private _defaultNotFoundItem = memoizeOne( | ||||
|     ( | ||||
|       label: this["notFoundLabel"], | ||||
|       localize?: LocalizeFunc | ||||
|       localize: LocalizeFunc | ||||
|     ): PickerComboBoxItemWithLabel => ({ | ||||
|       id: NO_MATCHING_ITEMS_FOUND_ID, | ||||
|       primary: | ||||
|         label || | ||||
|         (localize && localize("ui.components.combo-box.no_match")) || | ||||
|         "No matching items found", | ||||
|       primary: label || localize("ui.components.combo-box.no_match"), | ||||
|       icon_path: mdiMagnify, | ||||
|       a11y_label: | ||||
|         label || | ||||
|         (localize && localize("ui.components.combo-box.no_match")) || | ||||
|         "No matching items found", | ||||
|       a11y_label: label || localize("ui.components.combo-box.no_match"), | ||||
|     }) | ||||
|   ); | ||||
|  | ||||
| @@ -197,13 +144,13 @@ export class HaPickerComboBox extends LitElement { | ||||
|         caseInsensitiveStringCompare( | ||||
|           entityA.sorting_label!, | ||||
|           entityB.sorting_label!, | ||||
|           this.hass?.locale.language ?? navigator.language | ||||
|           this.hass.locale.language | ||||
|         ) | ||||
|       ); | ||||
|  | ||||
|     if (!sortedItems.length) { | ||||
|       sortedItems.push( | ||||
|         this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) | ||||
|         this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) | ||||
|       ); | ||||
|     } | ||||
|  | ||||
| @@ -212,73 +159,99 @@ export class HaPickerComboBox extends LitElement { | ||||
|     return sortedItems; | ||||
|   }; | ||||
|  | ||||
|   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>`; | ||||
|   }; | ||||
|   protected shouldUpdate(changedProps: PropertyValues) { | ||||
|     if ( | ||||
|       changedProps.has("value") || | ||||
|       changedProps.has("label") || | ||||
|       changedProps.has("disabled") | ||||
|     ) { | ||||
|       return true; | ||||
|     } | ||||
|     return !(!changedProps.has("_opened") && this._opened); | ||||
|   } | ||||
|  | ||||
|   @eventOptions({ passive: true }) | ||||
|   private _onScrollList(ev) { | ||||
|     const top = ev.target.scrollTop ?? 0; | ||||
|     this._listScrolled = top > 0; | ||||
|   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> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private get _value() { | ||||
|     return this.value || ""; | ||||
|   } | ||||
|  | ||||
|   private _valueSelected = (ev: Event) => { | ||||
|   private _openedChanged(ev: ValueChangedEvent<boolean>) { | ||||
|     ev.stopPropagation(); | ||||
|     const value = (ev.currentTarget as any).value as string; | ||||
|     const newValue = value?.trim(); | ||||
|     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(); | ||||
|  | ||||
|     if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     fireEvent(this, "value-changed", { value: newValue }); | ||||
|   }; | ||||
|     if (newValue !== this._value) { | ||||
|       this._setValue(newValue); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => | ||||
|     Fuse.createIndex(["search_labels"], states) | ||||
|   ); | ||||
|  | ||||
|   private _filterChanged = (ev: Event) => { | ||||
|     const textfield = ev.target as HaTextField; | ||||
|     const searchString = textfield.value.trim(); | ||||
|   private _filterChanged(ev: CustomEvent): void { | ||||
|     if (!this._opened) return; | ||||
|  | ||||
|     if (!searchString) { | ||||
|       this._items = this._allItems; | ||||
|       return; | ||||
|     } | ||||
|     const target = ev.target as HaComboBox; | ||||
|     const searchString = ev.detail.value.trim() as string; | ||||
|  | ||||
|     const index = this._fuseIndex(this._allItems); | ||||
|     const fuse = new HaFuse( | ||||
|       this._allItems, | ||||
|       { | ||||
|         shouldSort: false, | ||||
|         minMatchCharLength: Math.min(searchString.length, 2), | ||||
|       }, | ||||
|       index | ||||
|     ); | ||||
|     const index = this._fuseIndex(this._items); | ||||
|     const fuse = new HaFuse(this._items, { shouldSort: false }, index); | ||||
|  | ||||
|     const results = fuse.multiTermsSearch(searchString); | ||||
|     let filteredItems = this._allItems as PickerComboBoxItem[]; | ||||
|     let filteredItems = this._items as PickerComboBoxItem[]; | ||||
|     if (results) { | ||||
|       const items = results.map((result) => result.item); | ||||
|       if (items.length === 0) { | ||||
|         items.push( | ||||
|           this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) | ||||
|           this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) | ||||
|         ); | ||||
|       } | ||||
|       const additionalItems = this._getAdditionalItems(searchString); | ||||
| @@ -287,279 +260,17 @@ export class HaPickerComboBox extends LitElement { | ||||
|     } | ||||
|  | ||||
|     if (this.searchFn) { | ||||
|       filteredItems = this.searchFn( | ||||
|         searchString, | ||||
|         filteredItems, | ||||
|         this._allItems | ||||
|       ); | ||||
|       filteredItems = this.searchFn(searchString, filteredItems, this._items); | ||||
|     } | ||||
|  | ||||
|     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, | ||||
|     }); | ||||
|     target.filteredItems = filteredItems; | ||||
|   } | ||||
|  | ||||
|   private _focusList() { | ||||
|     if (this._selectedItemIndex === -1) { | ||||
|       this._selectNextItem(); | ||||
|     } | ||||
|   private _setValue(value: string | undefined) { | ||||
|     setTimeout(() => { | ||||
|       fireEvent(this, "value-changed", { value }); | ||||
|     }, 0); | ||||
|   } | ||||
|  | ||||
|   private _selectNextItem = (ev?: KeyboardEvent) => { | ||||
|     ev?.stopPropagation(); | ||||
|     ev?.preventDefault(); | ||||
|     if (!this._virtualizerElement) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this._searchFieldElement?.focus(); | ||||
|  | ||||
|     const items = this._virtualizerElement.items as PickerComboBoxItem[]; | ||||
|  | ||||
|     const maxItems = items.length - 1; | ||||
|  | ||||
|     if (maxItems === -1) { | ||||
|       this._resetSelectedItem(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const nextIndex = | ||||
|       maxItems === this._selectedItemIndex | ||||
|         ? this._selectedItemIndex | ||||
|         : this._selectedItemIndex + 1; | ||||
|  | ||||
|     if (!items[nextIndex]) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       // Skip titles, padding and empty search | ||||
|       if (nextIndex === maxItems) { | ||||
|         return; | ||||
|       } | ||||
|       this._selectedItemIndex = nextIndex + 1; | ||||
|     } else { | ||||
|       this._selectedItemIndex = nextIndex; | ||||
|     } | ||||
|  | ||||
|     this._scrollToSelectedItem(); | ||||
|   }; | ||||
|  | ||||
|   private _selectPreviousItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     ev.preventDefault(); | ||||
|     if (!this._virtualizerElement) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (this._selectedItemIndex > 0) { | ||||
|       const nextIndex = this._selectedItemIndex - 1; | ||||
|  | ||||
|       const items = this._virtualizerElement.items as PickerComboBoxItem[]; | ||||
|  | ||||
|       if (!items[nextIndex]) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|         // Skip titles, padding and empty search | ||||
|         if (nextIndex === 0) { | ||||
|           return; | ||||
|         } | ||||
|         this._selectedItemIndex = nextIndex - 1; | ||||
|       } else { | ||||
|         this._selectedItemIndex = nextIndex; | ||||
|       } | ||||
|  | ||||
|       this._scrollToSelectedItem(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _selectFirstItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     if (!this._virtualizerElement || !this._virtualizerElement.items.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const nextIndex = 0; | ||||
|  | ||||
|     if ( | ||||
|       (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === | ||||
|       NO_MATCHING_ITEMS_FOUND_ID | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (typeof this._virtualizerElement.items[nextIndex] === "string") { | ||||
|       this._selectedItemIndex = nextIndex + 1; | ||||
|     } else { | ||||
|       this._selectedItemIndex = nextIndex; | ||||
|     } | ||||
|  | ||||
|     this._scrollToSelectedItem(); | ||||
|   }; | ||||
|  | ||||
|   private _selectLastItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     if (!this._virtualizerElement || !this._virtualizerElement.items.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const nextIndex = this._virtualizerElement.items.length - 1; | ||||
|  | ||||
|     if ( | ||||
|       (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === | ||||
|       NO_MATCHING_ITEMS_FOUND_ID | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (typeof this._virtualizerElement.items[nextIndex] === "string") { | ||||
|       this._selectedItemIndex = nextIndex - 1; | ||||
|     } else { | ||||
|       this._selectedItemIndex = nextIndex; | ||||
|     } | ||||
|  | ||||
|     this._scrollToSelectedItem(); | ||||
|   }; | ||||
|  | ||||
|   private _scrollToSelectedItem = () => { | ||||
|     this._virtualizerElement | ||||
|       ?.querySelector(".selected") | ||||
|       ?.classList.remove("selected"); | ||||
|  | ||||
|     this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end"); | ||||
|  | ||||
|     requestAnimationFrame(() => { | ||||
|       this._virtualizerElement | ||||
|         ?.querySelector(`#list-item-${this._selectedItemIndex}`) | ||||
|         ?.classList.add("selected"); | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   private _pickSelectedItem = (ev: KeyboardEvent) => { | ||||
|     ev.stopPropagation(); | ||||
|     const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem; | ||||
|  | ||||
|     if ( | ||||
|       this._virtualizerElement?.items.length === 1 && | ||||
|       firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID | ||||
|     ) { | ||||
|       fireEvent(this, "value-changed", { | ||||
|         value: firstItem.id, | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (this._selectedItemIndex === -1) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // if filter button is focused | ||||
|     ev.preventDefault(); | ||||
|  | ||||
|     const item = this._virtualizerElement?.items[ | ||||
|       this._selectedItemIndex | ||||
|     ] as PickerComboBoxItem; | ||||
|     if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { | ||||
|       fireEvent(this, "value-changed", { value: item.id }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   private _resetSelectedItem() { | ||||
|     this._virtualizerElement | ||||
|       ?.querySelector(".selected") | ||||
|       ?.classList.remove("selected"); | ||||
|     this._selectedItemIndex = -1; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       :host { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         padding-top: var(--ha-space-3); | ||||
|         flex: 1; | ||||
|       } | ||||
|  | ||||
|       ha-textfield { | ||||
|         padding: 0 var(--ha-space-3); | ||||
|         margin-bottom: var(--ha-space-3); | ||||
|       } | ||||
|  | ||||
|       :host([mode="dialog"]) ha-textfield { | ||||
|         padding: 0 var(--ha-space-4); | ||||
|       } | ||||
|  | ||||
|       ha-combo-box-item { | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       ha-combo-box-item.selected { | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         ha-combo-box-item.selected { | ||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer { | ||||
|         flex: 1; | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer:focus-visible { | ||||
|         outline: none; | ||||
|       } | ||||
|  | ||||
|       lit-virtualizer.scrolled { | ||||
|         border-top: 1px solid var(--ha-color-border-neutral-quiet); | ||||
|       } | ||||
|  | ||||
|       .bottom-padding { | ||||
|         height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); | ||||
|         width: 100%; | ||||
|       } | ||||
|  | ||||
|       .empty { | ||||
|         text-align: center; | ||||
|       } | ||||
|  | ||||
|       .combo-box-row { | ||||
|         display: flex; | ||||
|         width: 100%; | ||||
|         align-items: center; | ||||
|         box-sizing: border-box; | ||||
|         min-height: 36px; | ||||
|       } | ||||
|       .combo-box-row.current-value { | ||||
|         background-color: var(--ha-color-fill-primary-quiet-resting); | ||||
|       } | ||||
|  | ||||
|       .combo-box-row.selected { | ||||
|         background-color: var(--ha-color-fill-neutral-quiet-hover); | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         .combo-box-row.selected { | ||||
|           background-color: var(--ha-color-fill-neutral-normal-hover); | ||||
|         } | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   | ||||
| @@ -137,7 +137,7 @@ export class HaSelect extends SelectBase { | ||||
|         height: var(--ha-select-height, 56px); | ||||
|       } | ||||
|       .mdc-select--filled .mdc-floating-label { | ||||
|         inset-inline-start: var(--ha-space-4); | ||||
|         inset-inline-start: 12px; | ||||
|         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: var(--ha-space-4); | ||||
|         padding-inline-start: 12px; | ||||
|         padding-inline-end: 0px; | ||||
|         direction: var(--direction); | ||||
|       } | ||||
| @@ -158,10 +158,7 @@ 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, | ||||
|           var(--ha-space-4) | ||||
|         ); | ||||
|         padding-inline-end: var(--select-selected-text-padding-end, 12px); | ||||
|       } | ||||
|       ha-icon-button { | ||||
|         position: absolute; | ||||
|   | ||||
							
								
								
									
										122
									
								
								src/components/ha-selector/ha-selector-background.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								src/components/ha-selector/ha-selector-background.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| import { css, html, LitElement } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { fireEvent } from "../../common/dom/fire_event"; | ||||
| import type { BackgroundSelector } from "../../data/selector"; | ||||
| import type { HomeAssistant } from "../../types"; | ||||
| import "../ha-picture-upload"; | ||||
| import "../ha-alert"; | ||||
| import type { HaPictureUpload } from "../ha-picture-upload"; | ||||
| import { URL_PREFIX } from "../../data/image_upload"; | ||||
|  | ||||
| @customElement("ha-selector-background") | ||||
| export class HaBackgroundSelector extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property() public value?: any; | ||||
|  | ||||
|   @property({ attribute: false }) public selector!: BackgroundSelector; | ||||
|  | ||||
|   @property({ type: Boolean }) public disabled = false; | ||||
|  | ||||
|   @property({ type: Boolean }) public required = true; | ||||
|  | ||||
|   @state() private yamlBackground = false; | ||||
|  | ||||
|   protected updated(changedProps) { | ||||
|     super.updated(changedProps); | ||||
|  | ||||
|     if (changedProps.has("value")) { | ||||
|       this.yamlBackground = !!this.value && !this.value.startsWith(URL_PREFIX); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     return html` | ||||
|       <div> | ||||
|         ${this.yamlBackground | ||||
|           ? html` | ||||
|               <div class="value"> | ||||
|                 <img | ||||
|                   src=${this.value} | ||||
|                   alt=${this.hass.localize( | ||||
|                     "ui.components.picture-upload.current_image_alt" | ||||
|                   )} | ||||
|                 /> | ||||
|               </div> | ||||
|               <ha-alert alert-type="info"> | ||||
|                 ${this.hass.localize( | ||||
|                   `ui.components.selectors.background.yaml_info` | ||||
|                 )} | ||||
|                 <ha-button slot="action" @click=${this._clearValue}> | ||||
|                   ${this.hass.localize( | ||||
|                     `ui.components.picture-upload.clear_picture` | ||||
|                   )} | ||||
|                 </ha-button> | ||||
|               </ha-alert> | ||||
|             ` | ||||
|           : html` | ||||
|               <ha-picture-upload | ||||
|                 .hass=${this.hass} | ||||
|                 .value=${this.value?.startsWith(URL_PREFIX) ? this.value : null} | ||||
|                 .original=${!!this.selector.background?.original} | ||||
|                 .cropOptions=${this.selector.background?.crop} | ||||
|                 select-media | ||||
|                 @change=${this._pictureChanged} | ||||
|               ></ha-picture-upload> | ||||
|             `} | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _pictureChanged(ev) { | ||||
|     const value = (ev.target as HaPictureUpload).value; | ||||
|  | ||||
|     fireEvent(this, "value-changed", { value: value ?? undefined }); | ||||
|   } | ||||
|  | ||||
|   private _clearValue() { | ||||
|     fireEvent(this, "value-changed", { value: undefined }); | ||||
|   } | ||||
|  | ||||
|   static styles = css` | ||||
|     :host { | ||||
|       display: block; | ||||
|       position: relative; | ||||
|     } | ||||
|     ha-picture-upload { | ||||
|       background-color: var(--primary-background-color); | ||||
|       border-radius: var(--file-upload-image-border-radius); | ||||
|     } | ||||
|     div { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|     ha-button { | ||||
|       white-space: nowrap; | ||||
|       --mdc-theme-primary: var(--primary-color); | ||||
|     } | ||||
|     .value { | ||||
|       width: 100%; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: center; | ||||
|     } | ||||
|     img { | ||||
|       max-width: 100%; | ||||
|       max-height: 200px; | ||||
|       margin-bottom: 4px; | ||||
|       border-radius: var(--file-upload-image-border-radius); | ||||
|       transition: opacity 0.3s; | ||||
|       opacity: var(--picture-opacity, 1); | ||||
|     } | ||||
|     img:hover { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   `; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "ha-selector-background": HaBackgroundSelector; | ||||
|   } | ||||
| } | ||||
| @@ -52,10 +52,9 @@ export class HaObjectSelector extends LitElement { | ||||
|     const translationKey = this.selector.object?.translation_key; | ||||
|  | ||||
|     if (this.localizeValue && translationKey) { | ||||
|       const label = | ||||
|         this.localizeValue(`${translationKey}.fields.${schema.name}.name`) || | ||||
|         // Fallback for backward compatibility | ||||
|         this.localizeValue(`${translationKey}.fields.${schema.name}`); | ||||
|       const label = this.localizeValue( | ||||
|         `${translationKey}.fields.${schema.name}` | ||||
|       ); | ||||
|       if (label) { | ||||
|         return label; | ||||
|       } | ||||
| @@ -63,20 +62,6 @@ 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 || | ||||
| @@ -229,7 +214,6 @@ 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 || false} | ||||
|         .allowName=${this.selector.ui_state_content?.allow_name} | ||||
|       ></ha-entity-state-content-picker> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -34,6 +34,7 @@ const LOAD_ELEMENTS = { | ||||
|   file: () => import("./ha-selector-file"), | ||||
|   floor: () => import("./ha-selector-floor"), | ||||
|   label: () => import("./ha-selector-label"), | ||||
|   background: () => import("./ha-selector-background"), | ||||
|   language: () => import("./ha-selector-language"), | ||||
|   navigation: () => import("./ha-selector-navigation"), | ||||
|   number: () => import("./ha-selector-number"), | ||||
|   | ||||
| @@ -53,7 +53,7 @@ class HaServicePicker extends LitElement { | ||||
|     item, | ||||
|     { index } | ||||
|   ) => html` | ||||
|     <ha-combo-box-item type="button" .borderTop=${index !== 0}> | ||||
|     <ha-combo-box-item type="button" border-top .borderTop=${index !== 0}> | ||||
|       <ha-service-icon | ||||
|         slot="start" | ||||
|         .hass=${this.hass} | ||||
| @@ -76,42 +76,34 @@ class HaServicePicker extends LitElement { | ||||
|     </ha-combo-box-item> | ||||
|   `; | ||||
|  | ||||
|   private _valueRenderer = memoizeOne( | ||||
|     ( | ||||
|       localize: LocalizeFunc, | ||||
|       services: HomeAssistant["services"] | ||||
|     ): PickerValueRenderer => | ||||
|       (value) => { | ||||
|         const serviceId = value; | ||||
|         const [domain, service] = serviceId.split("."); | ||||
|   private _valueRenderer: PickerValueRenderer = (value) => { | ||||
|     const serviceId = value; | ||||
|     const [domain, service] = serviceId.split("."); | ||||
|  | ||||
|         if (!services[domain]?.[service]) { | ||||
|           return html` | ||||
|             <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon> | ||||
|             <span slot="headline">${value}</span> | ||||
|           `; | ||||
|         } | ||||
|     if (!this.hass.services[domain]?.[service]) { | ||||
|       return html` | ||||
|         <ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon> | ||||
|         <span slot="headline">${value}</span> | ||||
|       `; | ||||
|     } | ||||
|  | ||||
|         const serviceName = | ||||
|           localize(`component.${domain}.services.${service}.name`) || | ||||
|           services[domain][service].name || | ||||
|           service; | ||||
|     const serviceName = | ||||
|       this.hass.localize(`component.${domain}.services.${service}.name`) || | ||||
|       this.hass.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 = | ||||
| @@ -131,10 +123,7 @@ class HaServicePicker extends LitElement { | ||||
|         .value=${this.value} | ||||
|         .getItems=${this._getItems} | ||||
|         .rowRenderer=${this._rowRenderer} | ||||
|         .valueRenderer=${this._valueRenderer( | ||||
|           this.hass.localize, | ||||
|           this.hass.services | ||||
|         )} | ||||
|         .valueRenderer=${this._valueRenderer} | ||||
|         @value-changed=${this._valueChanged} | ||||
|       > | ||||
|       </ha-generic-picker> | ||||
| @@ -173,9 +162,7 @@ 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,7 +29,6 @@ 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"; | ||||
| @@ -537,17 +536,11 @@ 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=${classMap({ | ||||
|           user: true, | ||||
|           selected: selectedPanel === "profile", | ||||
|           rtl: isRTL, | ||||
|         })} | ||||
|         class="user ${selectedPanel === "profile" ? " selected" : ""}" | ||||
|         @mouseenter=${this._itemMouseEnter} | ||||
|         @mouseleave=${this._itemMouseLeave} | ||||
|       > | ||||
| @@ -673,7 +666,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, var(--ha-space-0)))`; | ||||
|     tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`; | ||||
|   } | ||||
|  | ||||
|   private _hideTooltip() { | ||||
| @@ -712,17 +705,13 @@ 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, var(--ha-space-0)) | ||||
|           ); | ||||
|           padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px)); | ||||
|         } | ||||
|         .menu { | ||||
|           height: calc( | ||||
|             var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0)) | ||||
|           ); | ||||
|           height: calc(var(--header-height) + var(--safe-area-inset-top, 0px)); | ||||
|           box-sizing: border-box; | ||||
|           display: flex; | ||||
|           padding: 0 var(--ha-space-1); | ||||
|           padding: 0 4px; | ||||
|           border-bottom: 1px solid transparent; | ||||
|           white-space: nowrap; | ||||
|           font-weight: var(--ha-font-weight-normal); | ||||
| @@ -737,17 +726,13 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           ); | ||||
|           font-size: var(--ha-font-size-xl); | ||||
|           align-items: center; | ||||
|           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-left: calc(4px + var(--safe-area-inset-left, 0px)); | ||||
|           padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px)); | ||||
|           padding-inline-end: initial; | ||||
|           padding-top: var(--safe-area-inset-top, var(--ha-space-0)); | ||||
|           padding-top: var(--safe-area-inset-top, 0px); | ||||
|         } | ||||
|         :host([expanded]) .menu { | ||||
|           width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0))); | ||||
|           width: calc(256px + var(--safe-area-inset-left, 0px)); | ||||
|         } | ||||
|         :host([narrow][expanded]) .menu { | ||||
|           width: 100%; | ||||
| @@ -763,8 +748,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           display: none; | ||||
|         } | ||||
|         :host([narrow]) .title { | ||||
|           margin: var(--ha-space-0); | ||||
|           padding: var(--ha-space-0) var(--ha-space-4); | ||||
|           margin: 0; | ||||
|           padding: 0 16px; | ||||
|         } | ||||
|         :host([expanded]) .title { | ||||
|           display: initial; | ||||
| @@ -776,16 +761,13 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|         ha-fade-in, | ||||
|         ha-md-list { | ||||
|           height: calc( | ||||
|             100% - var(--header-height) - var( | ||||
|                 --safe-area-inset-top, | ||||
|                 var(--ha-space-0) | ||||
|               ) - | ||||
|             100% - var(--header-height) - var(--safe-area-inset-top, 0px) - | ||||
|               132px | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         ha-fade-in { | ||||
|           padding: var(--ha-space-1) var(--ha-space-0); | ||||
|           padding: 4px 0; | ||||
|           box-sizing: border-box; | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
| @@ -795,29 +777,29 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|         ha-md-list { | ||||
|           overflow-x: hidden; | ||||
|           background: none; | ||||
|           margin-left: var(--safe-area-inset-left, var(--ha-space-0)); | ||||
|           margin-left: var(--safe-area-inset-left, 0px); | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item { | ||||
|           flex-shrink: 0; | ||||
|           box-sizing: border-box; | ||||
|           margin: var(--ha-space-1); | ||||
|           margin: 4px; | ||||
|           border-radius: var(--ha-border-radius-sm); | ||||
|           --md-list-item-one-line-container-height: var(--ha-space-10); | ||||
|           --md-list-item-one-line-container-height: 40px; | ||||
|           --md-list-item-top-space: 0; | ||||
|           --md-list-item-bottom-space: 0; | ||||
|           width: var(--ha-space-12); | ||||
|           width: 48px; | ||||
|           position: relative; | ||||
|           --md-list-item-label-text-color: var(--sidebar-text-color); | ||||
|           --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); | ||||
|           --md-list-item-leading-space: 12px; | ||||
|           --md-list-item-trailing-space: 12px; | ||||
|           --md-list-item-leading-icon-size: 24px; | ||||
|         } | ||||
|         :host([expanded]) ha-md-list-item { | ||||
|           width: 248px; | ||||
|         } | ||||
|         :host([narrow][expanded]) ha-md-list-item { | ||||
|           width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0))); | ||||
|           width: calc(240px - var(--safe-area-inset-left, 0px)); | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item.selected { | ||||
| @@ -841,7 +823,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|  | ||||
|         ha-icon[slot="start"], | ||||
|         ha-svg-icon[slot="start"] { | ||||
|           width: var(--ha-space-6); | ||||
|           width: 24px; | ||||
|           flex-shrink: 0; | ||||
|           color: var(--sidebar-icon-color); | ||||
|         } | ||||
| @@ -874,7 +856,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
|           align-items: center; | ||||
|           min-width: var(--ha-space-2); | ||||
|           min-width: 8px; | ||||
|           border-radius: var(--ha-border-radius-xl); | ||||
|           font-weight: var(--ha-font-weight-normal); | ||||
|           line-height: normal; | ||||
| @@ -885,26 +867,22 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|  | ||||
|         ha-svg-icon + .badge { | ||||
|           position: absolute; | ||||
|           top: var(--ha-space-1); | ||||
|           top: 4px; | ||||
|           left: 26px; | ||||
|           border-radius: var(--ha-border-radius-md); | ||||
|           font-size: 0.65em; | ||||
|           line-height: var(--ha-line-height-expanded); | ||||
|           padding: var(--ha-space-0) var(--ha-space-1); | ||||
|           padding: 0 4px; | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item.user { | ||||
|           --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); | ||||
|           --md-list-item-leading-icon-size: 40px; | ||||
|           --md-list-item-leading-space: 4px; | ||||
|         } | ||||
|  | ||||
|         ha-user-badge { | ||||
|           flex-shrink: 0; | ||||
|           margin-right: calc(var(--ha-space-2) * -1); | ||||
|           margin-right: -8px; | ||||
|         } | ||||
|  | ||||
|         .spacer { | ||||
| @@ -916,7 +894,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: var(--ha-space-4); | ||||
|           padding: 16px; | ||||
|           white-space: nowrap; | ||||
|         } | ||||
|  | ||||
| @@ -928,7 +906,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { | ||||
|           white-space: nowrap; | ||||
|           color: var(--sidebar-background-color); | ||||
|           background-color: var(--sidebar-text-color); | ||||
|           padding: var(--ha-space-1); | ||||
|           padding: 4px; | ||||
|           font-weight: var(--ha-font-weight-medium); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -2,13 +2,26 @@ 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 TopAppBarFixedBase { | ||||
| export class HaTopAppBarFixed extends ViewTransitionMixin(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); | ||||
| @@ -23,6 +36,10 @@ export class HaTopAppBarFixed extends 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,14 +10,15 @@ 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 { haStyleScrollbar } from "../resources/styles"; | ||||
| import { ViewTransitionMixin } from "../mixins/view-transition-mixin"; | ||||
| import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles"; | ||||
|  | ||||
| export const passiveEventOptionsIfSupported = supportsPassiveEventListener | ||||
|   ? { passive: true } | ||||
|   : undefined; | ||||
|  | ||||
| @customElement("ha-two-pane-top-app-bar-fixed") | ||||
| export class TopAppBarBaseBase extends BaseElement { | ||||
| export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) { | ||||
|   protected override mdcFoundation!: MDCFixedTopAppBarFoundation; | ||||
|  | ||||
|   protected override mdcFoundationClass = MDCFixedTopAppBarFoundation; | ||||
| @@ -144,7 +145,12 @@ export class TopAppBarBaseBase extends BaseElement { | ||||
|           : nothing} | ||||
|         <div class="main"> | ||||
|           ${this.pane ? html`<div class="shadow-container"></div>` : nothing} | ||||
|           <div class="content"> | ||||
|           <div | ||||
|             class=${classMap({ | ||||
|               content: true, | ||||
|               loading: !this._loaded, | ||||
|             })} | ||||
|           > | ||||
|             <slot></slot> | ||||
|           </div> | ||||
|         </div> | ||||
| @@ -245,6 +251,7 @@ export class TopAppBarBaseBase extends BaseElement { | ||||
|   static override styles = [ | ||||
|     styles, | ||||
|     haStyleScrollbar, | ||||
|     haStyleViewTransitions, | ||||
|     css` | ||||
|       header { | ||||
|         padding-top: var(--safe-area-inset-top); | ||||
| @@ -341,6 +348,10 @@ export class TopAppBarBaseBase extends 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,18 +1,12 @@ | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import "@home-assistant/webawesome/dist/components/dialog/dialog"; | ||||
| import { mdiClose } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { | ||||
|   customElement, | ||||
|   eventOptions, | ||||
|   property, | ||||
|   query, | ||||
|   state, | ||||
| } from "lit/decorators"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import "./ha-dialog-header"; | ||||
| import "./ha-icon-button"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| import { fireEvent } from "../common/dom/fire_event"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
|  | ||||
| export type DialogWidth = "small" | "medium" | "large" | "full"; | ||||
|  | ||||
| @@ -96,11 +90,6 @@ export class HaWaDialog extends LitElement { | ||||
|   @state() | ||||
|   private _open = false; | ||||
|  | ||||
|   @query(".body") public bodyContainer!: HTMLDivElement; | ||||
|  | ||||
|   @state() | ||||
|   private _bodyScrolled = false; | ||||
|  | ||||
|   protected updated( | ||||
|     changedProperties: Map<string | number | symbol, unknown> | ||||
|   ): void { | ||||
| @@ -118,14 +107,10 @@ export class HaWaDialog extends LitElement { | ||||
|         .lightDismiss=${!this.preventScrimClose} | ||||
|         without-header | ||||
|         @wa-show=${this._handleShow} | ||||
|         @wa-after-show=${this._handleAfterShow} | ||||
|         @wa-after-hide=${this._handleAfterHide} | ||||
|       > | ||||
|         <slot name="header"> | ||||
|           <ha-dialog-header | ||||
|             .subtitlePosition=${this.headerSubtitlePosition} | ||||
|             .showBorder=${this._bodyScrolled} | ||||
|           > | ||||
|           <ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}> | ||||
|             <slot name="headerNavigationIcon" slot="navigationIcon"> | ||||
|               <ha-icon-button | ||||
|                 data-dialog="close" | ||||
| @@ -144,7 +129,7 @@ export class HaWaDialog extends LitElement { | ||||
|             <slot name="headerActionItems" slot="actionItems"></slot> | ||||
|           </ha-dialog-header> | ||||
|         </slot> | ||||
|         <div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}> | ||||
|         <div class="body ha-scrollbar"> | ||||
|           <slot></slot> | ||||
|         </div> | ||||
|         <slot name="footer" slot="footer"></slot> | ||||
| @@ -161,10 +146,6 @@ export class HaWaDialog extends LitElement { | ||||
|     (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); | ||||
|   }; | ||||
|  | ||||
|   private _handleAfterShow = () => { | ||||
|     fireEvent(this, "after-show"); | ||||
|   }; | ||||
|  | ||||
|   private _handleAfterHide = () => { | ||||
|     this._open = false; | ||||
|     fireEvent(this, "closed"); | ||||
| @@ -175,11 +156,6 @@ 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` | ||||
| @@ -196,7 +172,7 @@ export class HaWaDialog extends LitElement { | ||||
|             ) | ||||
|           ) | ||||
|         ); | ||||
|         --width: min(var(--ha-dialog-width-md, 580px), var(--full-width)); | ||||
|         --width: var(--ha-dialog-width-md, min(580px, var(--full-width))); | ||||
|         --spacing: var(--dialog-content-padding, var(--ha-space-6)); | ||||
|         --show-duration: var(--ha-dialog-show-duration, 200ms); | ||||
|         --hide-duration: var(--ha-dialog-hide-duration, 200ms); | ||||
| @@ -217,11 +193,11 @@ export class HaWaDialog extends LitElement { | ||||
|       } | ||||
|  | ||||
|       :host([width="small"]) wa-dialog { | ||||
|         --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width)); | ||||
|         --width: var(--ha-dialog-width-sm, min(320px, var(--full-width))); | ||||
|       } | ||||
|  | ||||
|       :host([width="large"]) wa-dialog { | ||||
|         --width: min(var(--ha-dialog-width-lg, 720px), var(--full-width)); | ||||
|         --width: var(--ha-dialog-width-lg, min(720px, var(--full-width))); | ||||
|       } | ||||
|  | ||||
|       :host([width="full"]) wa-dialog { | ||||
| @@ -235,7 +211,6 @@ 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; | ||||
| @@ -309,7 +284,6 @@ export class HaWaDialog extends LitElement { | ||||
|       } | ||||
|       :host([flexcontent]) .body { | ||||
|         max-width: 100%; | ||||
|         flex: 1; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
| @@ -338,7 +312,6 @@ declare global { | ||||
|  | ||||
|   interface HASSDomEvents { | ||||
|     opened: undefined; | ||||
|     "after-show": undefined; | ||||
|     closed: undefined; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -15,7 +15,6 @@ 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 { | ||||
| @@ -694,12 +693,10 @@ export class HaMediaPlayerBrowse extends LitElement { | ||||
|                 ` | ||||
|               : ""} | ||||
|           </div> | ||||
|           <ha-tooltip .for="grid-${slugify(child.title)}" distance="-4"> | ||||
|           <ha-tooltip .for="grid-${child.title}" distance="-4"> | ||||
|             ${child.title} | ||||
|           </ha-tooltip> | ||||
|           <div .id="grid-${slugify(child.title)}" class="title"> | ||||
|             ${child.title} | ||||
|           </div> | ||||
|           <div .id="grid-${child.title}" class="title">${child.title}</div> | ||||
|         </ha-card> | ||||
|       </div> | ||||
|     `; | ||||
|   | ||||
| @@ -1,15 +1,17 @@ | ||||
| import { html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { mdiClose } from "@mdi/js"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, query, 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"; | ||||
|  | ||||
| @@ -19,15 +21,14 @@ class DialogTargetDetails extends LitElement implements HassDialog { | ||||
|  | ||||
|   @state() private _params?: TargetDetailsDialogParams; | ||||
|  | ||||
|   @state() private _opened = false; | ||||
|   @query("ha-md-dialog") private _dialog?: HaMdDialog; | ||||
|  | ||||
|   public showDialog(params: TargetDetailsDialogParams): void { | ||||
|     this._params = params; | ||||
|     this._opened = true; | ||||
|   } | ||||
|  | ||||
|   public closeDialog() { | ||||
|     this._opened = false; | ||||
|     this._dialog?.close(); | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
| @@ -42,31 +43,58 @@ class DialogTargetDetails extends LitElement implements HassDialog { | ||||
|     } | ||||
|  | ||||
|     return html` | ||||
|       <ha-wa-dialog | ||||
|         .hass=${this.hass} | ||||
|         .open=${this._opened} | ||||
|         header-title=${this.hass.localize( | ||||
|           "ui.components.target-picker.target_details" | ||||
|         )} | ||||
|         header-subtitle=${`${this.hass.localize( | ||||
|           `ui.components.target-picker.type.${this._params.type}` | ||||
|         )}: | ||||
|             ${this._params.title}`} | ||||
|         @closed=${this._dialogClosed} | ||||
|       > | ||||
|         <ha-target-picker-item-row | ||||
|           .hass=${this.hass} | ||||
|           .type=${this._params.type} | ||||
|           .itemId=${this._params.itemId} | ||||
|           .deviceFilter=${this._params.deviceFilter} | ||||
|           .entityFilter=${this._params.entityFilter} | ||||
|           .includeDomains=${this._params.includeDomains} | ||||
|           .includeDeviceClasses=${this._params.includeDeviceClasses} | ||||
|           expand | ||||
|         ></ha-target-picker-item-row> | ||||
|       </ha-wa-dialog> | ||||
|       <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> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   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,12 +162,11 @@ export class HaTargetPickerItemRow extends LitElement { | ||||
|         <div slot="headline">${name}</div> | ||||
|         ${context && !this.hideContext | ||||
|           ? html`<span slot="supporting-text">${context}</span>` | ||||
|           : nothing} | ||||
|         ${this._domainName && this.subEntry | ||||
|           ? html`<span slot="supporting-text" class="domain" | ||||
|               >${this._domainName}</span | ||||
|             >` | ||||
|           : nothing} | ||||
|           : this._domainName && this.subEntry | ||||
|             ? html`<span slot="supporting-text" class="domain" | ||||
|                 >${this._domainName}</span | ||||
|               >` | ||||
|             : nothing} | ||||
|         ${!this.subEntry && entries && showEntities | ||||
|           ? html` | ||||
|               <div slot="end" class="summary"> | ||||
| @@ -232,11 +231,9 @@ export class HaTargetPickerItemRow extends LitElement { | ||||
|     const rows1 = | ||||
|       (nextType === "area" | ||||
|         ? entries?.referenced_areas | ||||
|         : nextType === "device" && this.type !== "label" | ||||
|         : nextType === "device" | ||||
|           ? entries?.referenced_devices | ||||
|           : this.type !== "label" | ||||
|             ? entries?.referenced_entities | ||||
|             : []) || []; | ||||
|           : entries?.referenced_entities) || []; | ||||
|  | ||||
|     const devicesInAreas = [] as string[]; | ||||
|  | ||||
| @@ -287,13 +284,9 @@ export class HaTargetPickerItemRow extends LitElement { | ||||
|  | ||||
|     const entityRows = | ||||
|       this.type === "label" && entries | ||||
|         ? entries.referenced_entities.filter((entity_id) => { | ||||
|             const entity = this.hass.entities[entity_id]; | ||||
|             return ( | ||||
|               entity.labels.includes(this.itemId) && | ||||
|               !entries.referenced_devices.includes(entity.device_id || "") | ||||
|             ); | ||||
|           }) | ||||
|         ? entries.referenced_entities.filter((entity_id) => | ||||
|             this.hass.entities[entity_id].labels.includes(this.itemId) | ||||
|           ) | ||||
|         : nextType === "device" && entries | ||||
|           ? entries.referenced_entities.filter( | ||||
|               (entity_id) => | ||||
| @@ -419,6 +412,7 @@ 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, | ||||
| @@ -675,14 +669,6 @@ 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,7 +16,6 @@ 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, | ||||
| @@ -103,7 +102,7 @@ export class HaTargetPickerValueChip extends LitElement { | ||||
|         ${this.type === "entity" | ||||
|           ? nothing | ||||
|           : html`<span role="gridcell"> | ||||
|               <ha-tooltip .for="expand-${slugify(this.itemId)}" | ||||
|               <ha-tooltip .for="expand-${this.itemId}" | ||||
|                 >${this.hass.localize( | ||||
|                   `ui.components.target-picker.expand_${this.type}_id` | ||||
|                 )} | ||||
| @@ -115,13 +114,13 @@ export class HaTargetPickerValueChip extends LitElement { | ||||
|                 )} | ||||
|                 .path=${mdiUnfoldMoreVertical} | ||||
|                 hide-title | ||||
|                 .id="expand-${slugify(this.itemId)}" | ||||
|                 .id="expand-${this.itemId}" | ||||
|                 .type=${this.type} | ||||
|                 @click=${this._handleExpand} | ||||
|               ></ha-icon-button> | ||||
|             </span>`} | ||||
|         <span role="gridcell"> | ||||
|           <ha-tooltip .for="remove-${slugify(this.itemId)}"> | ||||
|           <ha-tooltip .for="remove-${this.itemId}"> | ||||
|             ${this.hass.localize( | ||||
|               `ui.components.target-picker.remove_${this.type}_id` | ||||
|             )} | ||||
| @@ -131,7 +130,7 @@ export class HaTargetPickerValueChip extends LitElement { | ||||
|             .label=${this.hass.localize("ui.components.target-picker.remove")} | ||||
|             .path=${mdiClose} | ||||
|             hide-title | ||||
|             .id="remove-${slugify(this.itemId)}" | ||||
|             .id="remove-${this.itemId}" | ||||
|             .type=${this.type} | ||||
|             @click=${this._removeItem} | ||||
|           ></ha-icon-button> | ||||
|   | ||||
| @@ -6,6 +6,8 @@ import { | ||||
|   mdiCallSplit, | ||||
|   mdiCodeBraces, | ||||
|   mdiDevices, | ||||
|   mdiDotsHorizontal, | ||||
|   mdiExcavator, | ||||
|   mdiFormatListNumbered, | ||||
|   mdiGestureDoubleTap, | ||||
|   mdiHandBackRight, | ||||
| @@ -14,10 +16,10 @@ import { | ||||
|   mdiRoomService, | ||||
|   mdiShuffleDisabled, | ||||
|   mdiTimerOutline, | ||||
|   mdiTools, | ||||
|   mdiTrafficLight, | ||||
| } from "@mdi/js"; | ||||
| import type { AutomationElementGroupCollection } from "./automation"; | ||||
| import type { Action } from "./script"; | ||||
| import type { AutomationElementGroup } from "./automation"; | ||||
|  | ||||
| export const ACTION_ICONS = { | ||||
|   condition: mdiAbTesting, | ||||
| @@ -46,73 +48,37 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([ | ||||
|   "variables", | ||||
| ]); | ||||
|  | ||||
| export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [ | ||||
|   { | ||||
|     groups: { | ||||
|       device_id: {}, | ||||
|       serviceGroups: {}, | ||||
| 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: {}, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label", | ||||
|     groups: { | ||||
|       helpers: {}, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     titleKey: "ui.panel.config.automation.editor.actions.groups.other.label", | ||||
|     groups: { | ||||
|   other: { | ||||
|     icon: mdiDotsHorizontal, | ||||
|     members: { | ||||
|       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,7 +4,6 @@ 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"; | ||||
| @@ -294,11 +293,6 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition { | ||||
|   not: Condition[]; | ||||
| } | ||||
|  | ||||
| export interface AutomationElementGroupCollection { | ||||
|   titleKey?: LocalizeKeys; | ||||
|   groups: AutomationElementGroup; | ||||
| } | ||||
|  | ||||
| export type AutomationElementGroup = Record< | ||||
|   string, | ||||
|   { icon?: string; members?: AutomationElementGroup } | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import { | ||||
|   mdiClockOutline, | ||||
|   mdiCodeBraces, | ||||
|   mdiDevices, | ||||
|   mdiDotsHorizontal, | ||||
|   mdiExcavator, | ||||
|   mdiGateOr, | ||||
|   mdiIdentifier, | ||||
|   mdiMapClock, | ||||
| @@ -13,7 +15,7 @@ import { | ||||
|   mdiStateMachine, | ||||
|   mdiWeatherSunny, | ||||
| } from "@mdi/js"; | ||||
| import type { AutomationElementGroupCollection } from "./automation"; | ||||
| import type { AutomationElementGroup } from "./automation"; | ||||
|  | ||||
| export const CONDITION_ICONS = { | ||||
|   device: mdiDevices, | ||||
| @@ -29,31 +31,25 @@ export const CONDITION_ICONS = { | ||||
|   zone: mdiMapMarkerRadius, | ||||
| }; | ||||
|  | ||||
| export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [ | ||||
|   { | ||||
|     groups: { | ||||
|       device: {}, | ||||
|       entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, | ||||
|       time_location: { | ||||
|         icon: mdiMapClock, | ||||
|         members: { sun: {}, time: {}, zone: {} }, | ||||
|       }, | ||||
|     }, | ||||
| export const CONDITION_GROUPS: AutomationElementGroup = { | ||||
|   device: {}, | ||||
|   entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, | ||||
|   time_location: { | ||||
|     icon: mdiMapClock, | ||||
|     members: { sun: {}, time: {}, zone: {} }, | ||||
|   }, | ||||
|   { | ||||
|     titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label", | ||||
|     groups: { | ||||
|   building_blocks: { | ||||
|     icon: mdiExcavator, | ||||
|     members: { and: {}, or: {}, not: {} }, | ||||
|   }, | ||||
|   other: { | ||||
|     icon: mdiDotsHorizontal, | ||||
|     members: { | ||||
|       template: {}, | ||||
|       trigger: {}, | ||||
|     }, | ||||
|   }, | ||||
| ] as const; | ||||
|  | ||||
| export const CONDITION_BUILDING_BLOCKS_GROUP = { | ||||
|   and: {}, | ||||
|   or: {}, | ||||
|   not: {}, | ||||
| }; | ||||
| } as const; | ||||
|  | ||||
| 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 (floorB.level ?? -9999) - (floorA.level ?? -9999); | ||||
|         return (floorA.level ?? 9999) - (floorB.level ?? 9999); | ||||
|       } | ||||
|       const nameA = floorA?.name ?? a; | ||||
|       const nameB = floorB?.name ?? b; | ||||
|   | ||||
| @@ -435,9 +435,9 @@ export const convertStatisticsToHistory = ( | ||||
|   Object.entries(orderedStatistics).forEach(([key, value]) => { | ||||
|     const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({ | ||||
|       s: e.mean != null ? e.mean.toString() : e.state!.toString(), | ||||
|       lc: e.end / 1000, | ||||
|       lc: e.start / 1000, | ||||
|       a: {}, | ||||
|       lu: e.end / 1000, | ||||
|       lu: e.start / 1000, | ||||
|     })); | ||||
|     statsHistoryStates[key] = entityHistoryStates; | ||||
|   }); | ||||
|   | ||||
| @@ -264,7 +264,6 @@ export const getLabels = ( | ||||
|   const items = outputLabels.map<PickerComboBoxItem>((label) => ({ | ||||
|     id: label.label_id, | ||||
|     primary: label.name, | ||||
|     secondary: label.description ?? "", | ||||
|     icon: label.icon || undefined, | ||||
|     icon_path: label.icon ? undefined : mdiLabel, | ||||
|     sorting_label: label.name, | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import type { MediaSelectorValue } from "../../selector"; | ||||
| import type { LovelaceBadgeConfig } from "./badge"; | ||||
| import type { LovelaceCardConfig } from "./card"; | ||||
| import type { LovelaceSectionRawConfig } from "./section"; | ||||
| @@ -9,7 +8,7 @@ export interface ShowViewConfig { | ||||
| } | ||||
|  | ||||
| export interface LovelaceViewBackgroundConfig { | ||||
|   image?: string | MediaSelectorValue; | ||||
|   image?: string; | ||||
|   opacity?: number; | ||||
|   size?: "auto" | "cover" | "contain"; | ||||
|   alignment?: | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import type { | ||||
| import { ensureArray } from "../common/array/ensure-array"; | ||||
| import { computeStateDomain } from "../common/entity/compute_state_domain"; | ||||
| import { supportsFeature } from "../common/entity/supports-feature"; | ||||
| import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; | ||||
| import { isHelperDomain } from "../panels/config/helpers/const"; | ||||
| import type { UiAction } from "../panels/lovelace/components/hui-action-editor"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
| @@ -46,6 +47,8 @@ export type Selector = | ||||
|   | FileSelector | ||||
|   | IconSelector | ||||
|   | LabelSelector | ||||
|   | ImageSelector | ||||
|   | BackgroundSelector | ||||
|   | LanguageSelector | ||||
|   | LocationSelector | ||||
|   | MediaSelector | ||||
| @@ -270,6 +273,14 @@ export interface IconSelector { | ||||
|   } | null; | ||||
| } | ||||
|  | ||||
| export interface ImageSelector { | ||||
|   image: { original?: boolean; crop?: CropOptions } | null; | ||||
| } | ||||
|  | ||||
| export interface BackgroundSelector { | ||||
|   background: { original?: boolean; crop?: CropOptions } | null; | ||||
| } | ||||
|  | ||||
| export interface LabelSelector { | ||||
|   label: { | ||||
|     multiple?: boolean; | ||||
| @@ -341,7 +352,6 @@ export interface NumberSelector { | ||||
| interface ObjectSelectorField { | ||||
|   selector: Selector; | ||||
|   label?: string; | ||||
|   description?: string; | ||||
|   required?: boolean; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { | ||||
|   mdiClockOutline, | ||||
|   mdiCodeBraces, | ||||
|   mdiDevices, | ||||
|   mdiDotsHorizontal, | ||||
|   mdiFormatListBulleted, | ||||
|   mdiGestureDoubleTap, | ||||
|   mdiMapClock, | ||||
| @@ -22,7 +23,7 @@ import { | ||||
|  | ||||
| import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; | ||||
| import type { | ||||
|   AutomationElementGroupCollection, | ||||
|   AutomationElementGroup, | ||||
|   Trigger, | ||||
|   TriggerList, | ||||
| } from "./automation"; | ||||
| @@ -48,26 +49,16 @@ export const TRIGGER_ICONS = { | ||||
|   list: mdiFormatListBulleted, | ||||
| }; | ||||
|  | ||||
| export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [ | ||||
|   { | ||||
|     groups: { | ||||
|       device: {}, | ||||
|       entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, | ||||
|       time_location: { | ||||
|         icon: mdiMapClock, | ||||
|         members: { | ||||
|           calendar: {}, | ||||
|           sun: {}, | ||||
|           time: {}, | ||||
|           time_pattern: {}, | ||||
|           zone: {}, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
| export const TRIGGER_GROUPS: AutomationElementGroup = { | ||||
|   device: {}, | ||||
|   entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, | ||||
|   time_location: { | ||||
|     icon: mdiMapClock, | ||||
|     members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} }, | ||||
|   }, | ||||
|   { | ||||
|     titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label", | ||||
|     groups: { | ||||
|   other: { | ||||
|     icon: mdiDotsHorizontal, | ||||
|     members: { | ||||
|       event: {}, | ||||
|       geo_location: {}, | ||||
|       homeassistant: {}, | ||||
| @@ -79,7 +70,7 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [ | ||||
|       persistent_notification: {}, | ||||
|     }, | ||||
|   }, | ||||
| ] as const; | ||||
| } as const; | ||||
|  | ||||
| export const isTriggerList = (trigger: Trigger): trigger is TriggerList => | ||||
|   "triggers" in trigger; | ||||
|   | ||||
| @@ -484,7 +484,7 @@ class DataEntryFlowDialog extends LitElement { | ||||
|         this._unsubDataEntryFlowProgress = undefined; | ||||
|       } | ||||
|       if (_step.next_flow[0] === "config_flow") { | ||||
|         showConfigFlowDialog(this, { | ||||
|         showConfigFlowDialog(this._params!.dialogParentElement!, { | ||||
|           continueFlowId: _step.next_flow[1], | ||||
|           carryOverDevices: this._devices( | ||||
|             this._params!.flowConfig.showDevices, | ||||
| @@ -496,23 +496,32 @@ class DataEntryFlowDialog extends LitElement { | ||||
|         }); | ||||
|       } else if (_step.next_flow[0] === "options_flow") { | ||||
|         if (_step.type === "create_entry") { | ||||
|           showOptionsFlowDialog(this, _step.result!, { | ||||
|             continueFlowId: _step.next_flow[1], | ||||
|             navigateToResult: this._params!.navigateToResult, | ||||
|             dialogClosedCallback: this._params!.dialogClosedCallback, | ||||
|           }); | ||||
|           showOptionsFlowDialog( | ||||
|             this._params!.dialogParentElement!, | ||||
|             _step.result!, | ||||
|             { | ||||
|               continueFlowId: _step.next_flow[1], | ||||
|               navigateToResult: this._params!.navigateToResult, | ||||
|               dialogClosedCallback: this._params!.dialogClosedCallback, | ||||
|             } | ||||
|           ); | ||||
|         } | ||||
|       } else if (_step.next_flow[0] === "config_subentries_flow") { | ||||
|         if (_step.type === "create_entry") { | ||||
|           showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], { | ||||
|             continueFlowId: _step.next_flow[1], | ||||
|             navigateToResult: this._params!.navigateToResult, | ||||
|             dialogClosedCallback: this._params!.dialogClosedCallback, | ||||
|           }); | ||||
|           showSubConfigFlowDialog( | ||||
|             this._params!.dialogParentElement!, | ||||
|             _step.result!, | ||||
|             _step.next_flow[0], | ||||
|             { | ||||
|               continueFlowId: _step.next_flow[1], | ||||
|               navigateToResult: this._params!.navigateToResult, | ||||
|               dialogClosedCallback: this._params!.dialogClosedCallback, | ||||
|             } | ||||
|           ); | ||||
|         } | ||||
|       } else { | ||||
|         this.closeDialog(); | ||||
|         showAlertDialog(this, { | ||||
|         showAlertDialog(this._params!.dialogParentElement!, { | ||||
|           text: this.hass.localize( | ||||
|             "ui.panel.config.integrations.config_flow.error", | ||||
|             { error: `Unsupported next flow type: ${_step.next_flow[0]}` } | ||||
|   | ||||
| @@ -678,8 +678,8 @@ export class MoreInfoDialog extends LitElement { | ||||
|           /* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */ | ||||
|           --vertical-align-dialog: flex-start; | ||||
|           --dialog-surface-margin-top: max( | ||||
|             var(--ha-space-10), | ||||
|             var(--safe-area-inset-top, var(--ha-space-0)) | ||||
|             40px, | ||||
|             var(--safe-area-inset-top, 0px) | ||||
|           ); | ||||
|           --dialog-content-padding: 0; | ||||
|         } | ||||
| @@ -698,15 +698,14 @@ export class MoreInfoDialog extends LitElement { | ||||
|         } | ||||
|  | ||||
|         ha-more-info-history-and-logbook { | ||||
|           padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6) | ||||
|             var(--ha-space-6); | ||||
|           padding: 8px 24px 24px 24px; | ||||
|           display: block; | ||||
|         } | ||||
|  | ||||
|         @media all and (max-width: 450px), all and (max-height: 500px) { | ||||
|           /* When in fullscreen dialog should be attached to top */ | ||||
|           ha-dialog { | ||||
|             --dialog-surface-margin-top: var(--ha-space-0); | ||||
|             --dialog-surface-margin-top: 0px; | ||||
|           } | ||||
|         } | ||||
|  | ||||
| @@ -731,8 +730,7 @@ export class MoreInfoDialog extends LitElement { | ||||
|           display: flex; | ||||
|           flex-direction: column; | ||||
|           align-items: flex-start; | ||||
|           margin: var(--ha-space-0) var(--ha-space-0) | ||||
|             calc(var(--ha-space-2) * -1) var(--ha-space-0); | ||||
|           margin: 0 0 -10px 0; | ||||
|         } | ||||
|  | ||||
|         .title p { | ||||
| @@ -754,9 +752,9 @@ export class MoreInfoDialog extends LitElement { | ||||
|           font-size: var(--ha-font-size-m); | ||||
|           line-height: 16px; | ||||
|           --mdc-icon-size: 16px; | ||||
|           padding: var(--ha-space-1); | ||||
|           margin: calc(var(--ha-space-1) * -1); | ||||
|           margin-top: calc(var(--ha-space-2) * -1); | ||||
|           padding: 4px; | ||||
|           margin: -4px; | ||||
|           margin-top: -10px; | ||||
|           background: none; | ||||
|           border: none; | ||||
|           outline: none; | ||||
|   | ||||
| @@ -1011,8 +1011,8 @@ export class QuickBar extends LitElement { | ||||
|             --mdc-dialog-max-width: 800px; | ||||
|             --mdc-dialog-min-width: 500px; | ||||
|             --dialog-surface-position: fixed; | ||||
|             --dialog-surface-top: var(--ha-space-10); | ||||
|             --mdc-dialog-max-height: calc(100% - var(--ha-space-18)); | ||||
|             --dialog-surface-top: 40px; | ||||
|             --mdc-dialog-max-height: calc(100% - 72px); | ||||
|           } | ||||
|         } | ||||
|  | ||||
| @@ -1055,8 +1055,8 @@ export class QuickBar extends LitElement { | ||||
|         } | ||||
|  | ||||
|         span.command-text { | ||||
|           margin-left: var(--ha-space-2); | ||||
|           margin-inline-start: var(--ha-space-2); | ||||
|           margin-left: 8px; | ||||
|           margin-inline-start: 8px; | ||||
|           margin-inline-end: initial; | ||||
|           direction: var(--direction); | ||||
|         } | ||||
| @@ -1069,8 +1069,8 @@ export class QuickBar extends LitElement { | ||||
|         ha-md-list-item.two-line { | ||||
|           --md-list-item-one-line-container-height: 64px; | ||||
|           --md-list-item-two-line-container-height: 64px; | ||||
|           --md-list-item-top-space: var(--ha-space-2); | ||||
|           --md-list-item-bottom-space: var(--ha-space-2); | ||||
|           --md-list-item-top-space: 8px; | ||||
|           --md-list-item-bottom-space: 8px; | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item.three-line { | ||||
| @@ -1078,8 +1078,8 @@ export class QuickBar extends LitElement { | ||||
|           --md-list-item-one-line-container-height: 72px; | ||||
|           --md-list-item-two-line-container-height: 72px; | ||||
|           --md-list-item-three-line-container-height: 72px; | ||||
|           --md-list-item-top-space: var(--ha-space-2); | ||||
|           --md-list-item-bottom-space: var(--ha-space-2); | ||||
|           --md-list-item-top-space: 8px; | ||||
|           --md-list-item-bottom-space: 8px; | ||||
|         } | ||||
|  | ||||
|         ha-md-list-item .code { | ||||
| @@ -1104,11 +1104,11 @@ export class QuickBar extends LitElement { | ||||
|         } | ||||
|  | ||||
|         ha-tip { | ||||
|           padding: var(--ha-space-5); | ||||
|           padding: 20px; | ||||
|         } | ||||
|  | ||||
|         .nothing-found { | ||||
|           padding: var(--ha-space-4) var(--ha-space-0); | ||||
|           padding: 16px 0px; | ||||
|           text-align: center; | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -193,12 +193,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement { | ||||
|                     ).map( | ||||
|                       (lang) => | ||||
|                         html`<ha-md-menu-item | ||||
|                           .value=${lang.id} | ||||
|                           .value=${lang.value} | ||||
|                           @click=${this._handlePickLanguage} | ||||
|                           @keydown=${this._handlePickLanguage} | ||||
|                           .selected=${this._language === lang.id} | ||||
|                           .selected=${this._language === lang.value} | ||||
|                         > | ||||
|                           ${lang.primary} | ||||
|                           ${lang.label} | ||||
|                         </ha-md-menu-item>` | ||||
|                     )} | ||||
|                   </ha-md-button-menu>` | ||||
|   | ||||
| @@ -37,6 +37,7 @@ | ||||
|         flex-direction: column; | ||||
|         justify-content: center; | ||||
|         align-items: center; | ||||
|         view-transition-name: layout-fade-out; | ||||
|       } | ||||
|       #ha-launch-screen svg { | ||||
|         width: 112px; | ||||
|   | ||||
| @@ -61,6 +61,7 @@ class HassLoadingScreen extends LitElement { | ||||
|           display: block; | ||||
|           height: 100%; | ||||
|           background-color: var(--primary-background-color); | ||||
|           view-transition-name: layout-fade-out; | ||||
|         } | ||||
|         .toolbar { | ||||
|           display: flex; | ||||
|   | ||||
| @@ -3,6 +3,7 @@ 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) => { | ||||
| @@ -43,7 +44,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 ReactiveElement { | ||||
| export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) { | ||||
|   @property({ attribute: false }) public route?: Route; | ||||
|  | ||||
|   protected routerOptions!: RouterOptions; | ||||
| @@ -310,16 +311,18 @@ export class HassRouterPage extends ReactiveElement { | ||||
|     page: string, | ||||
|     routeOptions: RouteOptions | ||||
|   ) { | ||||
|     if (this.lastChild) { | ||||
|       this.removeChild(this.lastChild); | ||||
|     } | ||||
|     this.startViewTransition(() => { | ||||
|       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,15 +1,17 @@ | ||||
| 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 { haStyleScrollbar } from "../resources/styles"; | ||||
| import { ViewTransitionMixin } from "../mixins/view-transition-mixin"; | ||||
| import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles"; | ||||
| import type { HomeAssistant } from "../types"; | ||||
|  | ||||
| @customElement("hass-subpage") | ||||
| class HassSubpage extends LitElement { | ||||
| class HassSubpage extends ViewTransitionMixin(LitElement) { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property() public header?: string; | ||||
| @@ -60,7 +62,14 @@ class HassSubpage extends LitElement { | ||||
|           <slot name="toolbar-icon"></slot> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="content ha-scrollbar" @scroll=${this._saveScrollPos}> | ||||
|       <div | ||||
|         class=${classMap({ | ||||
|           content: true, | ||||
|           "ha-scrollbar": true, | ||||
|           loading: !this._loaded, | ||||
|         })} | ||||
|         @scroll=${this._saveScrollPos} | ||||
|       > | ||||
|         <slot></slot> | ||||
|       </div> | ||||
|       <div id="fab"> | ||||
| @@ -85,6 +94,7 @@ class HassSubpage extends LitElement { | ||||
|   static get styles(): CSSResultGroup { | ||||
|     return [ | ||||
|       haStyleScrollbar, | ||||
|       haStyleViewTransitions, | ||||
|       css` | ||||
|         :host { | ||||
|           display: block; | ||||
| @@ -167,6 +177,10 @@ class HassSubpage extends 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,7 +11,8 @@ import "../components/ha-icon-button-arrow-prev"; | ||||
| import "../components/ha-menu-button"; | ||||
| import "../components/ha-svg-icon"; | ||||
| import "../components/ha-tab"; | ||||
| import { haStyleScrollbar } from "../resources/styles"; | ||||
| import { ViewTransitionMixin } from "../mixins/view-transition-mixin"; | ||||
| import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles"; | ||||
| import type { HomeAssistant, Route } from "../types"; | ||||
|  | ||||
| export interface PageNavigation { | ||||
| @@ -29,7 +30,7 @@ export interface PageNavigation { | ||||
| } | ||||
|  | ||||
| @customElement("hass-tabs-subpage") | ||||
| class HassTabsSubpage extends LitElement { | ||||
| class HassTabsSubpage extends ViewTransitionMixin(LitElement) { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @property({ type: Boolean }) public supervisor = false; | ||||
| @@ -185,7 +186,12 @@ class HassTabsSubpage extends LitElement { | ||||
|             </div>` | ||||
|           : nothing} | ||||
|         <div | ||||
|           class="content ha-scrollbar ${classMap({ tabs: showTabs })}" | ||||
|           class=${classMap({ | ||||
|             content: true, | ||||
|             "ha-scrollbar": true, | ||||
|             tabs: showTabs, | ||||
|             loading: !this._loaded, | ||||
|           })} | ||||
|           @scroll=${this._saveScrollPos} | ||||
|         > | ||||
|           <slot></slot> | ||||
| @@ -214,6 +220,7 @@ class HassTabsSubpage extends LitElement { | ||||
|   static get styles(): CSSResultGroup { | ||||
|     return [ | ||||
|       haStyleScrollbar, | ||||
|       haStyleViewTransitions, | ||||
|       css` | ||||
|         :host { | ||||
|           display: block; | ||||
| @@ -332,6 +339,10 @@ class HassTabsSubpage extends 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); | ||||
|   | ||||
							
								
								
									
										201
									
								
								src/mixins/view-transition-mixin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								src/mixins/view-transition-mixin.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| import type { PropertyValues, ReactiveElement } from "lit"; | ||||
| import { state } from "lit/decorators"; | ||||
|  | ||||
| /** | ||||
|  * Abstract constructor type for a class that extends a reactive element | ||||
|  * @param T - The type of the reactive element | ||||
|  * @returns The abstract constructor | ||||
|  */ | ||||
| type AbstractConstructor<T extends ReactiveElement> = abstract new ( | ||||
|   ...args: any[] | ||||
| ) => T; | ||||
|  | ||||
| /** | ||||
|  * ViewTransitionMixin - Adds view transition support to reactive elements | ||||
|  * | ||||
|  * This mixin provides automatic fade-in transitions when content loads using the | ||||
|  * View Transition API. User preferences are respected for reduced motion. | ||||
|  * Falls back to synchronous updates for browsers that don't support the API. | ||||
|  * | ||||
|  * @example | ||||
|  * Basic usage: | ||||
|  * ```typescript | ||||
|  * @customElement("my-component") | ||||
|  * class MyComponent extends ViewTransitionMixin(LitElement) { | ||||
|  *   render() { | ||||
|  *     return html` | ||||
|  *       <div class=${classMap({ content: true, loading: !this._loaded })}> | ||||
|  *         <slot></slot> | ||||
|  *       </div> | ||||
|  *     `; | ||||
|  *   } | ||||
|  * | ||||
|  *   static styles = css` | ||||
|  *     .content { | ||||
|  *       view-transition-name: layout-fade-in; | ||||
|  *     } | ||||
|  *     .content.loading { | ||||
|  *       opacity: 0; // Hidden during initial load for transition | ||||
|  *     } | ||||
|  *   `; | ||||
|  * } | ||||
|  * ``` | ||||
|  * | ||||
|  * @example | ||||
|  * Triggering transitions manually: | ||||
|  * ```typescript | ||||
|  * private _switchView() { | ||||
|  *   this.startViewTransition(() => { | ||||
|  *     // DOM updates here will be animated | ||||
|  *     this.currentView = newView; | ||||
|  *   }); | ||||
|  * } | ||||
|  * ``` | ||||
|  * | ||||
|  * @example | ||||
|  * Custom load behavior: | ||||
|  * ```typescript | ||||
|  * protected override onLoadTransition(): void { | ||||
|  *   // Custom logic before triggering transition | ||||
|  *   this.startViewTransition(() => { | ||||
|  *     this._loaded = true; | ||||
|  *     this._additionalSetup(); | ||||
|  *   }); | ||||
|  * } | ||||
|  * ``` | ||||
|  * | ||||
|  * Features: | ||||
|  * - Automatic fade-in transition when slotted content loads | ||||
|  * - Provides `_loaded` state property for conditional rendering | ||||
|  * - `startViewTransition()` method for manual transitions | ||||
|  * - Respects prefers-reduced-motion user preference | ||||
|  * - Falls back gracefully when View Transition API unavailable | ||||
|  * - Automatic cleanup of event listeners | ||||
|  * | ||||
|  * The mixin monitors the default slot and triggers `onLoadTransition()` when | ||||
|  * content is available. Override `onLoadTransition()` to customize this behavior. | ||||
|  */ | ||||
| export const ViewTransitionMixin = < | ||||
|   T extends AbstractConstructor<ReactiveElement>, | ||||
| >( | ||||
|   superClass: T | ||||
| ) => { | ||||
|   abstract class ViewTransitionClass extends superClass { | ||||
|     /** | ||||
|      * Reference to the default (unnamed) slot element for monitoring content changes. | ||||
|      * Used to detect when slotted content is available to trigger load transitions. | ||||
|      */ | ||||
|     private _slot?: HTMLSlotElement; | ||||
|  | ||||
|     /** | ||||
|      * Prevents multiple slotchange events from triggering the transition more than once. | ||||
|      * Once content loads and transition starts, this flag ensures it won't retrigger. | ||||
|      */ | ||||
|     private _transitionTriggered = false; | ||||
|  | ||||
|     /** | ||||
|      * State property indicating whether content has finished loading. | ||||
|      * Use this in templates with the loading class pattern to hide content until ready. | ||||
|      */ | ||||
|     @state() protected _loaded = false; | ||||
|  | ||||
|     /** | ||||
|      * Trigger a view transition if supported by the browser | ||||
|      * @param updateCallback - Callback function that updates the DOM | ||||
|      * @returns Promise that resolves when the transition is complete | ||||
|      */ | ||||
|     protected async startViewTransition( | ||||
|       updateCallback: () => void | Promise<void> | ||||
|     ): Promise<void> { | ||||
|       if ( | ||||
|         !document.startViewTransition || | ||||
|         window.matchMedia("(prefers-reduced-motion: reduce)").matches | ||||
|       ) { | ||||
|         // Fallback: update without view transition | ||||
|         await updateCallback(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const transition = document.startViewTransition(async () => { | ||||
|         await updateCallback(); | ||||
|       }); | ||||
|  | ||||
|       try { | ||||
|         await transition.finished; | ||||
|       } catch { | ||||
|         // View transition failed - this is non-critical, continue silently | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Callback executed when content is ready to transition in. | ||||
|      * | ||||
|      * Called automatically when: | ||||
|      * - The default slot receives content (slotchange event) | ||||
|      * - No slot exists in the component (triggers immediately after firstUpdated) | ||||
|      * | ||||
|      * Default implementation sets `_loaded = true` within a view transition. | ||||
|      * Override this method to add custom logic before or during the transition, | ||||
|      * but ensure you call `startViewTransition()` to maintain transition behavior. | ||||
|      */ | ||||
|     protected onLoadTransition(): void { | ||||
|       this.startViewTransition(() => { | ||||
|         this._loaded = true; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if slot has content and trigger transition if it does | ||||
|      */ | ||||
|     private _checkSlotContent = (): void => { | ||||
|       // Guard against multiple slotchange events triggering the transition multiple times | ||||
|       if (this._transitionTriggered) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (this._slot) { | ||||
|         const elements = this._slot.assignedElements(); | ||||
|         if (elements.length > 0) { | ||||
|           this._transitionTriggered = true; | ||||
|           this.onLoadTransition(); | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
|      * Automatically apply view transition on first render | ||||
|      * @param changedProperties - Properties that changed | ||||
|      */ | ||||
|     protected firstUpdated(changedProperties: PropertyValues): void { | ||||
|       super.firstUpdated(changedProperties); | ||||
|  | ||||
|       // Wait for slotted content to be ready, then trigger transition | ||||
|       // Only monitor the default (unnamed) slot - named slots are for specific purposes | ||||
|       this._slot = this.shadowRoot?.querySelector("slot:not([name])") as | ||||
|         | HTMLSlotElement | ||||
|         | undefined; | ||||
|       if (this._slot) { | ||||
|         this._checkSlotContent(); | ||||
|         this._slot.addEventListener("slotchange", this._checkSlotContent); | ||||
|       } else { | ||||
|         // Start transition immediately if no slot is found | ||||
|         this.onLoadTransition(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cleanup event listeners when component is removed from the DOM. | ||||
|      * Removes the slotchange listener. | ||||
|      */ | ||||
|     override disconnectedCallback(): void { | ||||
|       super.disconnectedCallback(); | ||||
|       if (this._slot) { | ||||
|         this._slot.removeEventListener("slotchange", this._checkSlotContent); | ||||
|         this._slot = undefined; | ||||
|         this._transitionTriggered = false; | ||||
|         this._loaded = false; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   return ViewTransitionClass; | ||||
| }; | ||||
| @@ -143,14 +143,9 @@ class DialogCalendarEventDetail extends LitElement { | ||||
|       this.hass.locale.time_zone, | ||||
|       this.hass.config.time_zone | ||||
|     ); | ||||
|     // For all-day events (date-only strings), parse without timezone to avoid offset issues | ||||
|     const start = isDate(this._data!.dtstart) | ||||
|       ? new Date(this._data!.dtstart + "T00:00:00") | ||||
|       : new TZDate(this._data!.dtstart, timeZone); | ||||
|     const endValue = isDate(this._data!.dtend) | ||||
|       ? new Date(this._data!.dtend + "T00:00:00") | ||||
|       : new TZDate(this._data!.dtend, timeZone); | ||||
|     // All day event end dates are exclusive in iCalendar format, subtract one day for display | ||||
|     const start = new TZDate(this._data!.dtstart, timeZone); | ||||
|     const endValue = new TZDate(this._data!.dtend, timeZone); | ||||
|     // All day events should be displayed as a day earlier | ||||
|     const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue; | ||||
|     // The range can be shortened when the start and end are on the same day. | ||||
|     if (isSameDay(start, end)) { | ||||
|   | ||||
| @@ -1,39 +1,40 @@ | ||||
| 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 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 { createCloseHeading } from "../../../components/ha-dialog"; | ||||
|  | ||||
| const cropOptions: CropOptions = { | ||||
|   round: false, | ||||
|   type: "image/jpeg", | ||||
|   quality: 0.75, | ||||
|   aspectRatio: 1.78, | ||||
| }; | ||||
|  | ||||
| const SENSOR_DOMAINS = ["sensor"]; | ||||
| @@ -138,7 +139,6 @@ 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,15 +265,19 @@ class DialogAreaDetail extends LitElement { | ||||
|               ${this.hass.localize("ui.common.delete")} | ||||
|             </ha-button>` | ||||
|           : nothing} | ||||
|         <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> | ||||
|         <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-dialog> | ||||
|     `; | ||||
|   } | ||||
|   | ||||
| @@ -1,10 +1,9 @@ | ||||
| import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; | ||||
| import deepClone from "deep-clone-simple"; | ||||
| import type { PropertyValues } from "lit"; | ||||
| import { html, LitElement, nothing } from "lit"; | ||||
| import { LitElement, html, 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"; | ||||
| @@ -16,18 +15,19 @@ 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,6 +136,17 @@ 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> | ||||
| @@ -211,6 +222,15 @@ 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,6 +214,17 @@ 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> | ||||
| @@ -231,6 +242,15 @@ 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,8 +36,7 @@ export default class HaAutomationSidebar extends LitElement { | ||||
|  | ||||
|   @property({ type: Boolean }) public narrow = false; | ||||
|  | ||||
|   @property({ type: Number, attribute: "sidebar-key" }) | ||||
|   public sidebarKey?: number; | ||||
|   @property({ attribute: "sidebar-key" }) public sidebarKey?: string; | ||||
|  | ||||
|   @state() private _yamlMode = false; | ||||
|  | ||||
|   | ||||
| @@ -101,7 +101,7 @@ export class HaManualAutomationEditor extends LitElement { | ||||
|  | ||||
|   @state() private _sidebarConfig?: SidebarConfig; | ||||
|  | ||||
|   @state() private _sidebarKey = 0; | ||||
|   @state() private _sidebarKey?: string; | ||||
|  | ||||
|   @storage({ | ||||
|     key: "automation-sidebar-width", | ||||
| @@ -350,9 +350,7 @@ export class HaManualAutomationEditor extends LitElement { | ||||
|     // deselect previous selected row | ||||
|     this._sidebarConfig?.close?.(); | ||||
|     this._sidebarConfig = ev.detail; | ||||
|  | ||||
|     // be sure the sidebar editor is recreated | ||||
|     this._sidebarKey++; | ||||
|     this._sidebarKey = JSON.stringify(this._sidebarConfig); | ||||
|  | ||||
|     await this._sidebarElement?.updateComplete; | ||||
|     this._sidebarElement?.focus(); | ||||
| @@ -377,7 +375,6 @@ export class HaManualAutomationEditor extends LitElement { | ||||
|         return; | ||||
|       } | ||||
|       this._sidebarConfig?.close(); | ||||
|       this._sidebarKey = 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,45 @@ | ||||
| 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,8 +44,7 @@ export default class HaAutomationSidebarAction extends LitElement { | ||||
|  | ||||
|   @property({ type: Boolean }) public narrow = false; | ||||
|  | ||||
|   @property({ type: Number, attribute: "sidebar-key" }) | ||||
|   public sidebarKey?: number; | ||||
|   @property({ attribute: "sidebar-key" }) public sidebarKey?: string; | ||||
|  | ||||
|   @state() private _warnings?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -44,8 +44,7 @@ export default class HaAutomationSidebarCondition extends LitElement { | ||||
|  | ||||
|   @property({ type: Boolean }) public narrow = false; | ||||
|  | ||||
|   @property({ type: Number, attribute: "sidebar-key" }) | ||||
|   public sidebarKey?: number; | ||||
|   @property({ attribute: "sidebar-key" }) public sidebarKey?: string; | ||||
|  | ||||
|   @state() private _warnings?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -26,8 +26,7 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement { | ||||
|  | ||||
|   @property({ type: Boolean }) public narrow = false; | ||||
|  | ||||
|   @property({ type: Number, attribute: "sidebar-key" }) | ||||
|   public sidebarKey?: number; | ||||
|   @property({ attribute: "sidebar-key" }) public sidebarKey?: string; | ||||
|  | ||||
|   @state() private _warnings?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -25,8 +25,7 @@ export default class HaAutomationSidebarScriptField extends LitElement { | ||||
|  | ||||
|   @property({ type: Boolean }) public narrow = false; | ||||
|  | ||||
|   @property({ type: Number, attribute: "sidebar-key" }) | ||||
|   public sidebarKey?: number; | ||||
|   @property({ attribute: "sidebar-key" }) public sidebarKey?: string; | ||||
|  | ||||
|   @state() private _warnings?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -37,8 +37,7 @@ export default class HaAutomationSidebarTrigger extends LitElement { | ||||
|  | ||||
|   @property({ type: Boolean }) public narrow = false; | ||||
|  | ||||
|   @property({ type: Number, attribute: "sidebar-key" }) | ||||
|   public sidebarKey?: number; | ||||
|   @property({ attribute: "sidebar-key" }) public sidebarKey?: string; | ||||
|  | ||||
|   @state() private _warnings?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -19,7 +19,6 @@ import { ensureArray } from "../../../../../common/array/ensure-array"; | ||||
| import { fireEvent } from "../../../../../common/dom/fire_event"; | ||||
| import { hasTemplate } from "../../../../../common/string/has-template"; | ||||
| import type { StateTrigger } from "../../../../../data/automation"; | ||||
| import { ANY_STATE_VALUE } from "../../../../../components/entity/const"; | ||||
| import type { HomeAssistant } from "../../../../../types"; | ||||
| import { baseTriggerStruct, forDictStruct } from "../../structs"; | ||||
| import type { TriggerElement } from "../ha-automation-trigger-row"; | ||||
| @@ -37,12 +36,14 @@ const stateTriggerStruct = assign( | ||||
|     trigger: literal("state"), | ||||
|     entity_id: optional(union([string(), array(string())])), | ||||
|     attribute: optional(string()), | ||||
|     from: optional(union([nullable(string()), array(string())])), | ||||
|     to: optional(union([nullable(string()), array(string())])), | ||||
|     from: optional(nullable(string())), | ||||
|     to: optional(nullable(string())), | ||||
|     for: optional(union([number(), string(), forDictStruct])), | ||||
|   }) | ||||
| ); | ||||
|  | ||||
| const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__"; | ||||
|  | ||||
| @customElement("ha-automation-trigger-state") | ||||
| export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
| @@ -56,12 +57,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|   } | ||||
|  | ||||
|   private _schema = memoizeOne( | ||||
|     ( | ||||
|       localize: LocalizeFunc, | ||||
|       attribute: string | undefined, | ||||
|       hideInFrom: string[], | ||||
|       hideInTo: string[] | ||||
|     ) => | ||||
|     (localize: LocalizeFunc, attribute) => | ||||
|       [ | ||||
|         { | ||||
|           name: "entity_id", | ||||
| @@ -135,7 +131,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|           }, | ||||
|           selector: { | ||||
|             state: { | ||||
|               multiple: true, | ||||
|               extra_options: (attribute | ||||
|                 ? [] | ||||
|                 : [ | ||||
| @@ -147,7 +142,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|                     }, | ||||
|                   ]) as any, | ||||
|               attribute: attribute, | ||||
|               hide_states: hideInFrom, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
| @@ -158,7 +152,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|           }, | ||||
|           selector: { | ||||
|             state: { | ||||
|               multiple: true, | ||||
|               extra_options: (attribute | ||||
|                 ? [] | ||||
|                 : [ | ||||
| @@ -170,7 +163,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|                     }, | ||||
|                   ]) as any, | ||||
|               attribute: attribute, | ||||
|               hide_states: hideInTo, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
| @@ -215,15 +207,13 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|       entity_id: ensureArray(this.trigger.entity_id), | ||||
|       for: trgFor, | ||||
|     }; | ||||
|  | ||||
|     data.to = this._normalizeStates(this.trigger.to, data.attribute); | ||||
|     data.from = this._normalizeStates(this.trigger.from, data.attribute); | ||||
|     const schema = this._schema( | ||||
|       this.hass.localize, | ||||
|       this.trigger.attribute, | ||||
|       data.to, | ||||
|       data.from | ||||
|     ); | ||||
|     if (!data.attribute && data.to === null) { | ||||
|       data.to = ANY_STATE_VALUE; | ||||
|     } | ||||
|     if (!data.attribute && data.from === null) { | ||||
|       data.from = ANY_STATE_VALUE; | ||||
|     } | ||||
|     const schema = this._schema(this.hass.localize, this.trigger.attribute); | ||||
|  | ||||
|     return html` | ||||
|       <ha-form | ||||
| @@ -241,58 +231,22 @@ export class HaStateTrigger extends LitElement implements TriggerElement { | ||||
|     ev.stopPropagation(); | ||||
|     const newTrigger = ev.detail.value; | ||||
|  | ||||
|     newTrigger.to = this._applyAnyStateExclusive( | ||||
|       newTrigger.to, | ||||
|       newTrigger.attribute | ||||
|     ); | ||||
|     newTrigger.from = this._applyAnyStateExclusive( | ||||
|       newTrigger.from, | ||||
|       newTrigger.attribute | ||||
|     ); | ||||
|     if (newTrigger.to === ANY_STATE_VALUE) { | ||||
|       newTrigger.to = newTrigger.attribute ? undefined : null; | ||||
|     } | ||||
|     if (newTrigger.from === ANY_STATE_VALUE) { | ||||
|       newTrigger.from = newTrigger.attribute ? undefined : null; | ||||
|     } | ||||
|  | ||||
|     Object.keys(newTrigger).forEach((key) => { | ||||
|       const val = newTrigger[key]; | ||||
|       if ( | ||||
|         val === undefined || | ||||
|         val === "" || | ||||
|         (Array.isArray(val) && val.length === 0) | ||||
|       ) { | ||||
|         delete newTrigger[key]; | ||||
|       } | ||||
|     }); | ||||
|     Object.keys(newTrigger).forEach((key) => | ||||
|       newTrigger[key] === undefined || newTrigger[key] === "" | ||||
|         ? delete newTrigger[key] | ||||
|         : {} | ||||
|     ); | ||||
|  | ||||
|     fireEvent(this, "value-changed", { value: newTrigger }); | ||||
|   } | ||||
|  | ||||
|   private _applyAnyStateExclusive( | ||||
|     val: string | string[] | null | undefined, | ||||
|     attribute?: string | ||||
|   ): string | string[] | null | undefined { | ||||
|     const anyStateSelected = Array.isArray(val) | ||||
|       ? val.includes(ANY_STATE_VALUE) | ||||
|       : val === ANY_STATE_VALUE; | ||||
|     if (anyStateSelected) { | ||||
|       // Any state is exclusive: null if no attribute, undefined if attribute | ||||
|       return attribute ? undefined : null; | ||||
|     } | ||||
|     return val; | ||||
|   } | ||||
|  | ||||
|   private _normalizeStates( | ||||
|     value: string | string[] | null | undefined, | ||||
|     attribute?: string | ||||
|   ): string[] { | ||||
|     // If no attribute is selected and backend value is null, | ||||
|     // expose it as the special ANY state option in the UI. | ||||
|     if (!attribute && value === null) { | ||||
|       return [ANY_STATE_VALUE]; | ||||
|     } | ||||
|     if (value === undefined || value === null) { | ||||
|       return []; | ||||
|     } | ||||
|     return ensureArray(value); | ||||
|   } | ||||
|  | ||||
|   private _computeLabelCallback = ( | ||||
|     schema: SchemaUnion<ReturnType<typeof this._schema>> | ||||
|   ): string => | ||||
|   | ||||
| @@ -98,7 +98,7 @@ class DialogCategoryDetail extends LitElement { | ||||
|         </div> | ||||
|         <ha-button | ||||
|           appearance="plain" | ||||
|           slot="secondaryAction" | ||||
|           slot="primaryAction" | ||||
|           @click=${this.closeDialog} | ||||
|         > | ||||
|           ${this.hass.localize("ui.common.cancel")} | ||||
|   | ||||
| @@ -5,8 +5,7 @@ import { fireEvent } from "../../../../common/dom/fire_event"; | ||||
| import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name"; | ||||
| import "../../../../components/ha-alert"; | ||||
| import "../../../../components/ha-area-picker"; | ||||
| import "../../../../components/ha-wa-dialog"; | ||||
| import "../../../../components/ha-dialog-footer"; | ||||
| import "../../../../components/ha-dialog"; | ||||
| import "../../../../components/ha-button"; | ||||
| import "../../../../components/ha-labels-picker"; | ||||
| import type { HaSwitch } from "../../../../components/ha-switch"; | ||||
| @@ -20,8 +19,6 @@ import type { DeviceRegistryDetailDialogParams } from "./show-dialog-device-regi | ||||
| class DialogDeviceRegistryDetail extends LitElement { | ||||
|   @property({ attribute: false }) public hass!: HomeAssistant; | ||||
|  | ||||
|   @state() private _open = false; | ||||
|  | ||||
|   @state() private _nameByUser!: string; | ||||
|  | ||||
|   @state() private _error?: string; | ||||
| @@ -45,15 +42,10 @@ class DialogDeviceRegistryDetail extends LitElement { | ||||
|     this._areaId = this._params.device.area_id || ""; | ||||
|     this._labels = this._params.device.labels || []; | ||||
|     this._disabledBy = this._params.device.disabled_by; | ||||
|     this._open = true; | ||||
|     await this.updateComplete; | ||||
|   } | ||||
|  | ||||
|   public closeDialog(): void { | ||||
|     this._open = false; | ||||
|   } | ||||
|  | ||||
|   private _dialogClosed(): void { | ||||
|     this._error = ""; | ||||
|     this._params = undefined; | ||||
|     fireEvent(this, "dialog-closed", { dialog: this.localName }); | ||||
| @@ -65,12 +57,10 @@ class DialogDeviceRegistryDetail extends LitElement { | ||||
|     } | ||||
|     const device = this._params.device; | ||||
|     return html` | ||||
|       <ha-wa-dialog | ||||
|         .hass=${this.hass} | ||||
|         .open=${this._open} | ||||
|         header-title=${computeDeviceNameDisplay(device, this.hass)} | ||||
|         prevent-scrim-close | ||||
|         @closed=${this._dialogClosed} | ||||
|       <ha-dialog | ||||
|         open | ||||
|         @closed=${this.closeDialog} | ||||
|         .heading=${computeDeviceNameDisplay(device, this.hass)} | ||||
|       > | ||||
|         <div> | ||||
|           ${this._error | ||||
| @@ -78,7 +68,6 @@ class DialogDeviceRegistryDetail extends LitElement { | ||||
|             : ""} | ||||
|           <div class="form"> | ||||
|             <ha-textfield | ||||
|               autofocus | ||||
|               .value=${this._nameByUser} | ||||
|               @input=${this._nameChanged} | ||||
|               .label=${this.hass.localize( | ||||
| @@ -86,6 +75,7 @@ class DialogDeviceRegistryDetail extends LitElement { | ||||
|               )} | ||||
|               .placeholder=${device.name || ""} | ||||
|               .disabled=${this._submitting} | ||||
|               dialogInitialFocus | ||||
|             ></ha-textfield> | ||||
|             <ha-area-picker | ||||
|               .hass=${this.hass} | ||||
| @@ -141,25 +131,22 @@ class DialogDeviceRegistryDetail extends LitElement { | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <ha-dialog-footer slot="footer"> | ||||
|           <ha-button | ||||
|             slot="secondaryAction" | ||||
|             @click=${this.closeDialog} | ||||
|             .disabled=${this._submitting} | ||||
|             appearance="plain" | ||||
|           > | ||||
|             ${this.hass.localize("ui.common.cancel")} | ||||
|           </ha-button> | ||||
|           <ha-button | ||||
|             slot="primaryAction" | ||||
|             @click=${this._updateEntry} | ||||
|             .disabled=${this._submitting} | ||||
|           > | ||||
|             ${this.hass.localize("ui.dialogs.device-registry-detail.update")} | ||||
|           </ha-button> | ||||
|         </ha-dialog-footer> | ||||
|       </ha-wa-dialog> | ||||
|         <ha-button | ||||
|           slot="secondaryAction" | ||||
|           @click=${this.closeDialog} | ||||
|           .disabled=${this._submitting} | ||||
|           appearance="plain" | ||||
|         > | ||||
|           ${this.hass.localize("ui.common.cancel")} | ||||
|         </ha-button> | ||||
|         <ha-button | ||||
|           slot="primaryAction" | ||||
|           @click=${this._updateEntry} | ||||
|           .disabled=${this._submitting} | ||||
|         > | ||||
|           ${this.hass.localize("ui.dialogs.device-registry-detail.update")} | ||||
|         </ha-button> | ||||
|       </ha-dialog> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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,8 +153,6 @@ 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,7 +3,6 @@ import { | ||||
|   mdiDevices, | ||||
|   mdiDotsVertical, | ||||
|   mdiHelpCircle, | ||||
|   mdiLabelOutline, | ||||
|   mdiPlus, | ||||
|   mdiRobot, | ||||
|   mdiShape, | ||||
| @@ -24,10 +23,8 @@ import type { | ||||
|   SortingChangedEvent, | ||||
| } from "../../../components/data-table/ha-data-table"; | ||||
| import "../../../components/ha-fab"; | ||||
| import "../../../components/ha-icon"; | ||||
| import "../../../components/ha-icon-button"; | ||||
| import type { HaMdMenu } from "../../../components/ha-md-menu"; | ||||
| import "../../../components/ha-svg-icon"; | ||||
| import "../../../components/ha-relative-time"; | ||||
| import type { | ||||
|   LabelRegistryEntry, | ||||
|   LabelRegistryEntryMutableParams, | ||||
| @@ -46,6 +43,7 @@ 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 { | ||||
| @@ -102,9 +100,7 @@ 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>` | ||||
|             : html`<ha-svg-icon .path=${mdiLabelOutline}></ha-svg-icon>`, | ||||
|           label.icon ? html`<ha-icon .icon=${label.icon}></ha-icon>` : nothing, | ||||
|       }, | ||||
|       color: { | ||||
|         title: "", | ||||
| @@ -112,18 +108,18 @@ export class HaConfigLabels extends LitElement { | ||||
|         label: localize("ui.panel.config.labels.headers.color"), | ||||
|         type: "icon", | ||||
|         template: (label) => | ||||
|           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>`, | ||||
|           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, | ||||
|       }, | ||||
|       name: { | ||||
|         title: localize("ui.panel.config.labels.headers.name"), | ||||
|   | ||||
| @@ -89,7 +89,7 @@ export class HaManualScriptEditor extends LitElement { | ||||
|  | ||||
|   @state() private _sidebarConfig?: SidebarConfig; | ||||
|  | ||||
|   @state() private _sidebarKey = 0; | ||||
|   @state() private _sidebarKey?: string; | ||||
|  | ||||
|   @storage({ | ||||
|     key: "automation-sidebar-width", | ||||
| @@ -512,9 +512,7 @@ export class HaManualScriptEditor extends LitElement { | ||||
|     // deselect previous selected row | ||||
|     this._sidebarConfig?.close?.(); | ||||
|     this._sidebarConfig = ev.detail; | ||||
|  | ||||
|     // be sure the sidebar editor is recreated | ||||
|     this._sidebarKey++; | ||||
|     this._sidebarKey = JSON.stringify(this._sidebarConfig); | ||||
|  | ||||
|     await this._sidebarElement?.updateComplete; | ||||
|     this._sidebarElement?.focus(); | ||||
| @@ -539,7 +537,6 @@ export class HaManualScriptEditor extends LitElement { | ||||
|         return; | ||||
|       } | ||||
|       this._sidebarConfig?.close(); | ||||
|       this._sidebarKey = 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -280,11 +280,10 @@ ${type === "object" | ||||
|  | ||||
|         .content.horizontal { | ||||
|           --code-mirror-max-height: calc( | ||||
|             100vh - var(--header-height) - | ||||
|               (var(--ha-line-height-normal) * var(--ha-font-size-m) * 3) - | ||||
|               (max(16px, var(--safe-area-inset-top)) * 2) - | ||||
|             100vh - var(--header-height) - (var(--ha-line-height-normal) * 3) - | ||||
|               (1em * 2) - (max(16px, var(--safe-area-inset-top)) * 2) - | ||||
|               (max(16px, var(--safe-area-inset-bottom)) * 2) - | ||||
|               (var(--ha-card-border-width, 1px) * 3) - (1em * 2) - 192px | ||||
|               (var(--ha-card-border-width, 1px) * 2) - 179px | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -313,14 +313,9 @@ class HaPanelHistory extends LitElement { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const statsStartDate = new Date(this._startDate); | ||||
|     // History uses the end datapoint of the statistic, so if we want the | ||||
|     // graph to start at 7AM, need to fetch the statistic from 6AM. | ||||
|     statsStartDate.setHours(statsStartDate.getHours() - 1); | ||||
|  | ||||
|     const statistics = await fetchStatistics( | ||||
|       this.hass!, | ||||
|       statsStartDate, | ||||
|       this._startDate, | ||||
|       this._endDate, | ||||
|       statisticIds, | ||||
|       "hour", | ||||
|   | ||||
| @@ -1,126 +0,0 @@ | ||||
| import { html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { computeDomain } from "../../../common/entity/compute_domain"; | ||||
| import { supportsFeature } from "../../../common/entity/supports-feature"; | ||||
| import "../../../components/ha-control-number-buttons"; | ||||
| import { isUnavailableState } from "../../../data/entity"; | ||||
| import { | ||||
|   MediaPlayerEntityFeature, | ||||
|   type MediaPlayerEntity, | ||||
| } from "../../../data/media-player"; | ||||
| import type { HomeAssistant } from "../../../types"; | ||||
| import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; | ||||
| import { cardFeatureStyles } from "./common/card-feature-styles"; | ||||
| import type { | ||||
|   LovelaceCardFeatureContext, | ||||
|   MediaPlayerVolumeButtonsCardFeatureConfig, | ||||
| } from "./types"; | ||||
| import { clamp } from "../../../common/number/clamp"; | ||||
|  | ||||
| export const supportsMediaPlayerVolumeButtonsCardFeature = ( | ||||
|   hass: HomeAssistant, | ||||
|   context: LovelaceCardFeatureContext | ||||
| ) => { | ||||
|   const stateObj = context.entity_id | ||||
|     ? hass.states[context.entity_id] | ||||
|     : undefined; | ||||
|   if (!stateObj) return false; | ||||
|   const domain = computeDomain(stateObj.entity_id); | ||||
|   return ( | ||||
|     domain === "media_player" && | ||||
|     supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET) | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| @customElement("hui-media-player-volume-buttons-card-feature") | ||||
| class HuiMediaPlayerVolumeButtonsCardFeature | ||||
|   extends LitElement | ||||
|   implements LovelaceCardFeature | ||||
| { | ||||
|   @property({ attribute: false }) public hass?: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: false }) public context?: LovelaceCardFeatureContext; | ||||
|  | ||||
|   @state() private _config?: MediaPlayerVolumeButtonsCardFeatureConfig; | ||||
|  | ||||
|   private get _stateObj() { | ||||
|     if (!this.hass || !this.context || !this.context.entity_id) { | ||||
|       return undefined; | ||||
|     } | ||||
|     return this.hass.states[this.context.entity_id] as | ||||
|       | MediaPlayerEntity | ||||
|       | undefined; | ||||
|   } | ||||
|  | ||||
|   static getStubConfig(): MediaPlayerVolumeButtonsCardFeatureConfig { | ||||
|     return { | ||||
|       type: "media-player-volume-buttons", | ||||
|       step: 5, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> { | ||||
|     await import( | ||||
|       "../editor/config-elements/hui-media-player-volume-buttons-card-feature-editor" | ||||
|     ); | ||||
|     return document.createElement( | ||||
|       "hui-media-player-volume-buttons-card-feature-editor" | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   public setConfig(config: MediaPlayerVolumeButtonsCardFeatureConfig): void { | ||||
|     if (!config) { | ||||
|       throw new Error("Invalid configuration"); | ||||
|     } | ||||
|     this._config = config; | ||||
|   } | ||||
|  | ||||
|   protected render() { | ||||
|     if ( | ||||
|       !this._config || | ||||
|       !this.hass || | ||||
|       !this.context || | ||||
|       !this._stateObj || | ||||
|       !supportsMediaPlayerVolumeButtonsCardFeature(this.hass, this.context) | ||||
|     ) { | ||||
|       return nothing; | ||||
|     } | ||||
|  | ||||
|     const position = | ||||
|       this._stateObj.attributes.volume_level != null | ||||
|         ? Math.round(this._stateObj.attributes.volume_level * 100) | ||||
|         : undefined; | ||||
|  | ||||
|     return html` | ||||
|       <ha-control-number-buttons | ||||
|         .disabled=${!this._stateObj || isUnavailableState(this._stateObj.state)} | ||||
|         .locale=${this.hass.locale} | ||||
|         min="0" | ||||
|         max="100" | ||||
|         .step=${this._config.step ?? 5} | ||||
|         .value=${position} | ||||
|         unit="%" | ||||
|         @value-changed=${this._valueChanged} | ||||
|       ></ha-control-number-buttons> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   private _valueChanged(ev: CustomEvent) { | ||||
|     ev.stopPropagation(); | ||||
|  | ||||
|     this.hass!.callService("media_player", "volume_set", { | ||||
|       entity_id: this._stateObj!.entity_id, | ||||
|       volume_level: clamp(ev.detail.value, 0, 100) / 100, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   static get styles() { | ||||
|     return cardFeatureStyles; | ||||
|   } | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|   interface HTMLElementTagNameMap { | ||||
|     "hui-media-player-volume-buttons-card-feature": HuiMediaPlayerVolumeButtonsCardFeature; | ||||
|   } | ||||
| } | ||||
| @@ -50,11 +50,6 @@ export interface MediaPlayerVolumeSliderCardFeatureConfig { | ||||
|   type: "media-player-volume-slider"; | ||||
| } | ||||
|  | ||||
| export interface MediaPlayerVolumeButtonsCardFeatureConfig { | ||||
|   type: "media-player-volume-buttons"; | ||||
|   step?: number; | ||||
| } | ||||
|  | ||||
| export interface FanDirectionCardFeatureConfig { | ||||
|   type: "fan-direction"; | ||||
| } | ||||
| @@ -257,7 +252,6 @@ export type LovelaceCardFeatureConfig = | ||||
|   | LockCommandsCardFeatureConfig | ||||
|   | LockOpenDoorCardFeatureConfig | ||||
|   | MediaPlayerPlaybackCardFeatureConfig | ||||
|   | MediaPlayerVolumeButtonsCardFeatureConfig | ||||
|   | MediaPlayerVolumeSliderCardFeatureConfig | ||||
|   | NumericInputCardFeatureConfig | ||||
|   | SelectOptionsCardFeatureConfig | ||||
|   | ||||
| @@ -34,7 +34,6 @@ 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 | ||||
| @@ -57,8 +56,6 @@ export class HuiEnergyDevicesGraphCard | ||||
|   }) | ||||
|   private _chartType: "bar" | "pie" = "bar"; | ||||
|  | ||||
|   @state() private _isMobile = false; | ||||
|  | ||||
|   private _compoundStats: string[] = []; | ||||
|  | ||||
|   protected hassSubscribeRequiredHostProps = ["_config"]; | ||||
| @@ -71,12 +68,6 @@ export class HuiEnergyDevicesGraphCard | ||||
|         this._data = data; | ||||
|         this._getStatistics(data); | ||||
|       }), | ||||
|       listenMediaQuery( | ||||
|         "all and (max-width: 450px), all and (max-height: 500px)", | ||||
|         (matches) => { | ||||
|           this._isMobile = matches; | ||||
|         } | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
| @@ -163,6 +154,9 @@ 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", | ||||
| @@ -181,7 +175,7 @@ export class HuiEnergyDevicesGraphCard | ||||
|             fontSize: 12, | ||||
|             margin: 5, | ||||
|             width: Math.min( | ||||
|               this._isMobile ? 100 : 200, | ||||
|               isMobile ? 100 : 200, | ||||
|               Math.max( | ||||
|                 ...(data[0]?.data?.map( | ||||
|                   (d: any) => | ||||
| @@ -241,14 +235,8 @@ 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: var(--ha-space-12); | ||||
|       --mdc-icon-size: 48px; | ||||
|       color: var(--tile-color); | ||||
|     } | ||||
|     .picture .icon-container::before { | ||||
| @@ -729,15 +729,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { | ||||
|     } | ||||
|     hui-card-features { | ||||
|       --feature-color: var(--tile-color); | ||||
|       padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3); | ||||
|       padding: 0 12px 12px 12px; | ||||
|     } | ||||
|     .container.horizontal hui-card-features { | ||||
|       width: calc( | ||||
|         50% - var(--column-gap, var(--ha-space-0)) / 2 - var(--ha-space-3) | ||||
|       ); | ||||
|       width: calc(50% - var(--column-gap, 0px) / 2 - 12px); | ||||
|       flex: none; | ||||
|       --feature-height: var(--ha-space-9); | ||||
|       padding: 0 var(--ha-space-3); | ||||
|       --feature-height: 36px; | ||||
|       padding: 0 12px; | ||||
|       padding-inline-start: 0; | ||||
|     } | ||||
|     .alert-badge { | ||||
| @@ -750,18 +748,18 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       gap: var(--ha-space-2); | ||||
|       padding: var(--ha-space-2); | ||||
|       padding: 8px; | ||||
|       pointer-events: none; | ||||
|       z-index: 1; | ||||
|     } | ||||
|     .alert { | ||||
|       background-color: var(--orange-color); | ||||
|       border-radius: var(--ha-border-radius-lg); | ||||
|       width: var(--ha-space-6); | ||||
|       height: var(--ha-space-6); | ||||
|       width: 24px; | ||||
|       height: 24px; | ||||
|       padding: 2px; | ||||
|       box-sizing: border-box; | ||||
|       --mdc-icon-size: var(--ha-space-4); | ||||
|       --mdc-icon-size: 16px; | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import type { PropertyValues, TemplateResult } from "lit"; | ||||
| import { css, html, LitElement, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators"; | ||||
| import { customElement, state } from "lit/decorators"; | ||||
| import { DOMAINS_TOGGLE } from "../../../common/const"; | ||||
| import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; | ||||
| import { computeDomain } from "../../../common/entity/compute_domain"; | ||||
| @@ -20,11 +20,9 @@ import type { | ||||
| import type { | ||||
|   LovelaceCard, | ||||
|   LovelaceCardEditor, | ||||
|   LovelaceGridOptions, | ||||
|   LovelaceHeaderFooter, | ||||
| } from "../types"; | ||||
| import type { EntitiesCardConfig } from "./types"; | ||||
| import { haStyleScrollbar } from "../../../resources/styles"; | ||||
|  | ||||
| export const computeShowHeaderToggle = < | ||||
|   T extends EntityConfig | LovelaceRowConfig, | ||||
| @@ -77,8 +75,6 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard { | ||||
|  | ||||
|   private _hass?: HomeAssistant; | ||||
|  | ||||
|   @property({ attribute: false }) public layout?: string; | ||||
|  | ||||
|   private _configEntities?: LovelaceRowConfig[]; | ||||
|  | ||||
|   private _showHeaderToggle?: boolean; | ||||
| @@ -143,14 +139,6 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard { | ||||
|     return size; | ||||
|   } | ||||
|  | ||||
|   public getGridOptions(): LovelaceGridOptions { | ||||
|     return { | ||||
|       columns: 12, | ||||
|       min_columns: 6, | ||||
|       min_rows: this._config?.title || this._showHeaderToggle ? 3 : 2, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   public setConfig(config: EntitiesCardConfig): void { | ||||
|     if (!config.entities || !Array.isArray(config.entities)) { | ||||
|       throw new Error("Entities must be specified"); | ||||
| @@ -245,7 +233,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard { | ||||
|                     `} | ||||
|               </h1> | ||||
|             `} | ||||
|         <div id="states" class="card-content ha-scrollbar"> | ||||
|         <div id="states" class="card-content"> | ||||
|           ${this._configEntities!.map((entityConf) => | ||||
|             this._renderEntity(entityConf) | ||||
|           )} | ||||
| @@ -258,73 +246,69 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard { | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   static styles = [ | ||||
|     haStyleScrollbar, | ||||
|     css` | ||||
|       ha-card { | ||||
|         height: 100%; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         justify-content: space-between; | ||||
|       } | ||||
|       .card-header { | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|       } | ||||
|   static styles = css` | ||||
|     ha-card { | ||||
|       height: 100%; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       justify-content: space-between; | ||||
|     } | ||||
|     .card-header { | ||||
|       display: flex; | ||||
|       justify-content: space-between; | ||||
|     } | ||||
|  | ||||
|       .card-header .name { | ||||
|         white-space: nowrap; | ||||
|         overflow: hidden; | ||||
|         text-overflow: ellipsis; | ||||
|       } | ||||
|     .card-header .name { | ||||
|       white-space: nowrap; | ||||
|       overflow: hidden; | ||||
|       text-overflow: ellipsis; | ||||
|     } | ||||
|  | ||||
|       #states { | ||||
|         flex: 1; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: var(--entities-card-row-gap, var(--card-row-gap, 8px)); | ||||
|         overflow-y: auto; | ||||
|       } | ||||
|     #states { | ||||
|       flex: 1; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       gap: var(--entities-card-row-gap, var(--card-row-gap, 8px)); | ||||
|     } | ||||
|  | ||||
|       #states > div > * { | ||||
|         overflow: clip visible; | ||||
|       } | ||||
|     #states > div > * { | ||||
|       overflow: clip visible; | ||||
|     } | ||||
|  | ||||
|       #states > div { | ||||
|         position: relative; | ||||
|       } | ||||
|     #states > div { | ||||
|       position: relative; | ||||
|     } | ||||
|  | ||||
|       .icon { | ||||
|         padding: 0px 18px 0px 8px; | ||||
|       } | ||||
|     .icon { | ||||
|       padding: 0px 18px 0px 8px; | ||||
|     } | ||||
|  | ||||
|       .header { | ||||
|         border-top-left-radius: var( | ||||
|           --ha-card-border-radius, | ||||
|           var(--ha-border-radius-lg) | ||||
|         ); | ||||
|         border-top-right-radius: var( | ||||
|           --ha-card-border-radius, | ||||
|           var(--ha-border-radius-lg) | ||||
|         ); | ||||
|         margin-bottom: 16px; | ||||
|         overflow: hidden; | ||||
|       } | ||||
|     .header { | ||||
|       border-top-left-radius: var( | ||||
|         --ha-card-border-radius, | ||||
|         var(--ha-border-radius-lg) | ||||
|       ); | ||||
|       border-top-right-radius: var( | ||||
|         --ha-card-border-radius, | ||||
|         var(--ha-border-radius-lg) | ||||
|       ); | ||||
|       margin-bottom: 16px; | ||||
|       overflow: hidden; | ||||
|     } | ||||
|  | ||||
|       .footer { | ||||
|         border-bottom-left-radius: var( | ||||
|           --ha-card-border-radius, | ||||
|           var(--ha-border-radius-lg) | ||||
|         ); | ||||
|         border-bottom-right-radius: var( | ||||
|           --ha-card-border-radius, | ||||
|           var(--ha-border-radius-lg) | ||||
|         ); | ||||
|         margin-top: -16px; | ||||
|         overflow: hidden; | ||||
|       } | ||||
|     `, | ||||
|   ]; | ||||
|     .footer { | ||||
|       border-bottom-left-radius: var( | ||||
|         --ha-card-border-radius, | ||||
|         var(--ha-border-radius-lg) | ||||
|       ); | ||||
|       border-bottom-right-radius: var( | ||||
|         --ha-card-border-radius, | ||||
|         var(--ha-border-radius-lg) | ||||
|       ); | ||||
|       margin-top: -16px; | ||||
|       overflow: hidden; | ||||
|     } | ||||
|   `; | ||||
|  | ||||
|   private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult { | ||||
|     const element = createRowElement( | ||||
|   | ||||
| @@ -162,7 +162,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard { | ||||
|   private async _fetchStatistics(sensorNumericDeviceClasses: string[]) { | ||||
|     const now = new Date(); | ||||
|     const start = new Date(); | ||||
|     start.setHours(start.getHours() - this._hoursToShow - 1); | ||||
|     start.setHours(start.getHours() - this._hoursToShow); | ||||
|  | ||||
|     const statistics = await fetchStatistics( | ||||
|       this.hass!, | ||||
|   | ||||
| @@ -14,11 +14,7 @@ import { findEntities } from "../common/find-entities"; | ||||
| import { processConfigEntities } from "../common/process-config-entities"; | ||||
| import "../components/hui-warning"; | ||||
| import type { EntityConfig } from "../entity-rows/types"; | ||||
| import type { | ||||
|   LovelaceCard, | ||||
|   LovelaceCardEditor, | ||||
|   LovelaceGridOptions, | ||||
| } from "../types"; | ||||
| import type { LovelaceCard, LovelaceCardEditor } from "../types"; | ||||
| import type { LogbookCardConfig } from "./types"; | ||||
| import { resolveEntityIDs } from "../../../data/selector"; | ||||
| import { ensureArray } from "../../../common/array/ensure-array"; | ||||
| @@ -68,15 +64,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { | ||||
|     return 9 + (this._config?.title ? 1 : 0); | ||||
|   } | ||||
|  | ||||
|   public getGridOptions(): LovelaceGridOptions { | ||||
|     return { | ||||
|       rows: 6, | ||||
|       columns: 12, | ||||
|       min_columns: 6, | ||||
|       min_rows: this._config?.title ? 4 : 3, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   public validateTarget( | ||||
|     config: LogbookCardConfig | ||||
|   ): HassServiceTarget | undefined { | ||||
| @@ -202,10 +189,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { | ||||
|       > | ||||
|         <div class="content"> | ||||
|           <ha-logbook | ||||
|             class=${classMap({ | ||||
|               "is-grid": this.layout === "grid", | ||||
|               "is-panel": this.layout === "panel", | ||||
|             })} | ||||
|             .hass=${this.hass} | ||||
|             .time=${this._time} | ||||
|             .entityIds=${this._getEntityIds()} | ||||
| @@ -229,7 +212,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { | ||||
|         } | ||||
|  | ||||
|         .content { | ||||
|           height: 100%; | ||||
|           padding: 0 16px 16px; | ||||
|         } | ||||
|  | ||||
| @@ -242,11 +224,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { | ||||
|           display: block; | ||||
|         } | ||||
|  | ||||
|         ha-logbook.is-grid, | ||||
|         ha-logbook.is-panel { | ||||
|           height: 100%; | ||||
|         } | ||||
|  | ||||
|         :host([ispanel]) .content, | ||||
|         :host([ispanel]) ha-logbook { | ||||
|           height: 100%; | ||||
|   | ||||
| @@ -447,13 +447,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard { | ||||
|     } | ||||
|     hui-card-features { | ||||
|       --feature-color: var(--tile-color); | ||||
|       padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3); | ||||
|       padding: 0 12px 12px 12px; | ||||
|     } | ||||
|     .container.horizontal hui-card-features { | ||||
|       width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3)); | ||||
|       width: calc(50% - var(--column-gap, 0px) / 2 - 12px); | ||||
|       flex: none; | ||||
|       --feature-height: var(--ha-space-9); | ||||
|       padding: 0 var(--ha-space-3); | ||||
|       --feature-height: 36px; | ||||
|       padding: 0 12px; | ||||
|       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: var(--ha-line-height-condensed); | ||||
|           line-height: 1; | ||||
|         } | ||||
|  | ||||
|         .name-state { | ||||
| @@ -729,7 +729,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { | ||||
|         .short .state, | ||||
|         .short .temp-attribute .temp { | ||||
|           font-size: 24px; | ||||
|           line-height: var(--ha-line-height-condensed); | ||||
|           line-height: 1.25; | ||||
|         } | ||||
|  | ||||
|         .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,6 +226,13 @@ 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; | ||||
|   | ||||
| @@ -23,7 +23,6 @@ import "../card-features/hui-light-color-temp-card-feature"; | ||||
| import "../card-features/hui-lock-commands-card-feature"; | ||||
| import "../card-features/hui-lock-open-door-card-feature"; | ||||
| import "../card-features/hui-media-player-playback-card-feature"; | ||||
| import "../card-features/hui-media-player-volume-buttons-card-feature"; | ||||
| import "../card-features/hui-media-player-volume-slider-card-feature"; | ||||
| import "../card-features/hui-numeric-input-card-feature"; | ||||
| import "../card-features/hui-select-options-card-feature"; | ||||
| @@ -73,7 +72,6 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([ | ||||
|   "lock-commands", | ||||
|   "lock-open-door", | ||||
|   "media-player-playback", | ||||
|   "media-player-volume-buttons", | ||||
|   "media-player-volume-slider", | ||||
|   "numeric-input", | ||||
|   "select-options", | ||||
|   | ||||
| @@ -48,7 +48,6 @@ import { supportsLightColorTempCardFeature } from "../../card-features/hui-light | ||||
| import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature"; | ||||
| import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature"; | ||||
| import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature"; | ||||
| import { supportsMediaPlayerVolumeButtonsCardFeature } from "../../card-features/hui-media-player-volume-buttons-card-feature"; | ||||
| import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature"; | ||||
| import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature"; | ||||
| import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature"; | ||||
| @@ -103,7 +102,6 @@ const UI_FEATURE_TYPES = [ | ||||
|   "lock-commands", | ||||
|   "lock-open-door", | ||||
|   "media-player-playback", | ||||
|   "media-player-volume-buttons", | ||||
|   "media-player-volume-slider", | ||||
|   "numeric-input", | ||||
|   "select-options", | ||||
| @@ -133,7 +131,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([ | ||||
|   "fan-preset-modes", | ||||
|   "humidifier-modes", | ||||
|   "lawn-mower-commands", | ||||
|   "media-player-volume-buttons", | ||||
|   "numeric-input", | ||||
|   "select-options", | ||||
|   "trend-graph", | ||||
| @@ -174,7 +171,6 @@ const SUPPORTS_FEATURE_TYPES: Record< | ||||
|   "lock-commands": supportsLockCommandsCardFeature, | ||||
|   "lock-open-door": supportsLockOpenDoorCardFeature, | ||||
|   "media-player-playback": supportsMediaPlayerPlaybackCardFeature, | ||||
|   "media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature, | ||||
|   "media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature, | ||||
|   "numeric-input": supportsNumericInputCardFeature, | ||||
|   "select-options": supportsSelectOptionsCardFeature, | ||||
| @@ -499,7 +495,7 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|       flex-direction: column; | ||||
|     } | ||||
|     ha-button-menu { | ||||
|       margin-top: var(--ha-space-2); | ||||
|       margin-top: 8px; | ||||
|     } | ||||
|     .feature { | ||||
|       display: flex; | ||||
| @@ -508,8 +504,8 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|     .feature .handle { | ||||
|       cursor: move; /* fallback if grab cursor is unsupported */ | ||||
|       cursor: grab; | ||||
|       padding-right: var(--ha-space-2); | ||||
|       padding-inline-end: var(--ha-space-2); | ||||
|       padding-right: 8px; | ||||
|       padding-inline-end: 8px; | ||||
|       padding-inline-start: initial; | ||||
|       direction: var(--direction); | ||||
|     } | ||||
| @@ -518,7 +514,7 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|     } | ||||
|  | ||||
|     .feature-content { | ||||
|       height: var(--ha-space-15); | ||||
|       height: 60px; | ||||
|       font-size: var(--ha-font-size-l); | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
| @@ -533,7 +529,7 @@ export class HuiCardFeaturesEditor extends LitElement { | ||||
|  | ||||
|     .remove-icon, | ||||
|     .edit-icon { | ||||
|       --mdc-icon-button-size: var(--ha-space-9); | ||||
|       --mdc-icon-button-size: 36px; | ||||
|       color: var(--secondary-text-color); | ||||
|     } | ||||
|  | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user