From d0ab20479f2a99e79bcc50b49b2fb6eb1b9691a7 Mon Sep 17 00:00:00 2001 From: Maarten Lakerveld Date: Thu, 30 Jul 2026 10:39:41 +0200 Subject: [PATCH 01/49] Tools > Template use WA Split Panel (#53021) * Add Web Awesome Split Panel component * Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/components/ha-code-editor.ts | 12 +- src/components/ha-split-panel.ts | 68 +++ .../config/tools/template/tools-template.ts | 570 +++++++++++------- src/translations/en.json | 3 + 4 files changed, 437 insertions(+), 216 deletions(-) create mode 100644 src/components/ha-split-panel.ts diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index aeed46cbd7..e2f8f9dff2 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items"; import type { CompletionItem } from "./ha-code-editor-completion-items"; import "./ha-icon"; import "./ha-icon-button-toolbar"; -import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar"; +import type { + HaIconButtonToolbar, + HaIconButtonToolbarItem, +} from "./ha-icon-button-toolbar"; declare global { interface HASSDomEvents { @@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement { @property({ type: Boolean, attribute: "has-test" }) public hasTest = false; + @property({ attribute: false }) + public toolbarItems?: (HaIconButtonToolbarItem | string)[]; + @property({ attribute: false }) public testing = false; @property({ type: String }) public placeholder?: string; @@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement { changedProps.has("_canCopy") || changedProps.has("_canUndo") || changedProps.has("_canRedo") || - changedProps.has("testing") + changedProps.has("testing") || + changedProps.has("toolbarItems") ) { this._updateToolbarButtons(); } @@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement { } this._editorToolbar.items = [ + ...(this.toolbarItems ?? []), ...(this.hasTest && !this._isFullscreen ? [ { diff --git a/src/components/ha-split-panel.ts b/src/components/ha-split-panel.ts new file mode 100644 index 0000000000..29f1389003 --- /dev/null +++ b/src/components/ha-split-panel.ts @@ -0,0 +1,68 @@ +import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel"; +import type { CSSResultGroup } from "lit"; +import { css } from "lit"; +import { customElement } from "lit/decorators"; + +@customElement("ha-split-panel") +export class HaSplitPanel extends SplitPanel { + static get styles(): CSSResultGroup { + return [ + SplitPanel.styles, + css` + :host { + --divider-width: var(--ha-split-panel-divider-width, 2px); + --divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px); + --min: var(--ha-split-panel-min, 0); + --max: var(--ha-split-panel-max, 100%); + } + + .divider { + background-color: var(--divider-color); + transition: background-color var(--ha-animation-duration-fast) + ease-out; + } + + /* Grip affordance so the divider reads as draggable. The divider + already centers its children via flexbox, so keep this in flow. + Consumers slotting their own divider handle can hide it with + --ha-split-panel-grip-display: none. */ + .divider::before { + content: ""; + width: 2px; + height: var(--ha-space-8); + display: var(--ha-split-panel-grip-display, block); + border-radius: var(--ha-border-radius-pill, 9999px); + background-color: var(--secondary-text-color); + opacity: 0.5; + transition: opacity var(--ha-animation-duration-fast) ease-out; + } + + /* In vertical orientation the divider is horizontal, so the grip pill + lies flat instead of standing upright. */ + :host([orientation="vertical"]) .divider::before { + width: var(--ha-space-8); + height: 2px; + } + + @media (hover: hover) { + :host(:not([disabled])) .divider:hover { + background-color: var(--primary-color); + } + :host(:not([disabled])) .divider:hover::before { + opacity: 1; + } + } + + :host(:not([disabled])) .divider:focus-visible { + background-color: var(--primary-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-split-panel": HaSplitPanel; + } +} diff --git a/src/panels/config/tools/template/tools-template.ts b/src/panels/config/tools/template/tools-template.ts index f1c8d41f2a..9fcfeb24a7 100644 --- a/src/panels/config/tools/template/tools-template.ts +++ b/src/panels/config/tools/template/tools-template.ts @@ -1,17 +1,27 @@ +import { + mdiRestore, + mdiTrashCanOutline, + mdiViewSplitHorizontal, + mdiViewSplitVertical, +} from "@mdi/js"; +import memoizeOne from "memoize-one"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { CSSResultGroup } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import type { HASSDomEvent } from "../../../../common/dom/fire_event"; +import { customElement, property, state } from "lit/decorators"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; import type { LocalizeKeys } from "../../../../common/translations/localize"; import { debounce } from "../../../../common/util/debounce"; import "../../../../components/ha-alert"; -import "../../../../components/ha-button"; import "../../../../components/ha-card"; import "../../../../components/ha-code-editor"; import "../../../../components/ha-expansion-panel"; +import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar"; +import "../../../../components/ha-label"; import "../../../../components/ha-spinner"; +import "../../../../components/ha-split-panel"; +import type { HaSplitPanel } from "../../../../components/ha-split-panel"; +import "../../../../components/ha-svg-icon"; import "../../../../components/ha-tip"; import type { RenderTemplateResult } from "../../../../data/ws-templates"; import { subscribeRenderTemplate } from "../../../../data/ws-templates"; @@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [ { key: "docs_functions", path: "/template-functions/" }, ]; +const STORAGE_KEY_TEMPLATE = "panel-dev-template-template"; +const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position"; +const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation"; +const DEFAULT_SPLIT_POSITION = 50; + +type SplitOrientation = "horizontal" | "vertical"; + @customElement("tools-template") class HaPanelDevTemplate extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ type: Boolean }) public narrow = false; + @property({ type: Boolean, reflect: true }) public narrow = false; @state() private _error?: string; @@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement { @state() private _unsubRenderTemplate?: Promise; - @state() private _descriptionExpanded = false; + @state() private _splitPosition = DEFAULT_SPLIT_POSITION; - @query("ha-tip") private _editorTip?: HTMLElement; + @state() private _splitOrientation: SplitOrientation = "horizontal"; private _template = ""; @@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement { // its late-arriving results discarded. private _subscribeRequestId = 0; - private _tipResizeObserver?: ResizeObserver; - public connectedCallback() { super.connectedCallback(); if (this._template && !this._unsubRenderTemplate) { @@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement { public disconnectedCallback() { super.disconnectedCallback(); this._unsubscribeTemplate(); - this._tipResizeObserver?.disconnect(); - this._tipResizeObserver = undefined; } protected firstUpdated() { - if (localStorage && localStorage["panel-dev-template-template"]) { - this._template = localStorage["panel-dev-template-template"]; + if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) { + this._template = localStorage[STORAGE_KEY_TEMPLATE]; } else { this._template = DEMO_TEMPLATE; } + const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION]; + if (storedPosition) { + const parsed = parseFloat(storedPosition); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) { + this._splitPosition = parsed; + } + } + if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") { + this._splitOrientation = "vertical"; + } this._subscribeTemplate(); - this._observeTipHeight(); this._inited = true; } @@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement { : "dict" : type; + const editorCard = this._renderEditorCard(); + const resultCard = this._renderResultCard(type, resultType); + + // On narrow viewports side-by-side is too cramped, so force the (still + // resizable) stacked layout and hide the orientation toggle. + const orientation = this.narrow ? "vertical" : this._splitOrientation; + return html` -
+

@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {

-
- -
- -
-
- - ${this.hass.localize( - "ui.panel.config.tools.tabs.templates.reset" - )} - - - ${this.hass.localize("ui.common.clear")} - -
- - ${this.hass.localize( - "ui.panel.config.tools.tabs.templates.keyboard_tip", - { - autocomplete: html`Ctrl+Space`, - } - )} - -
- -
- ${ - this._rendering - ? html`` - : "" - } - ${ - this._error - ? html`${this._error}` - : nothing - } - ${ - this._templateResult - ? html`
+      
+        
${editorCard}
+
${resultCard}
+ ${this.narrow ? nothing : this._renderOrientationToggle()} +
+ `; + } + + private _renderOrientationToggle() { + const label = this.hass.localize( + this._splitOrientation === "vertical" + ? "ui.panel.config.tools.tabs.templates.layout_side_by_side" + : "ui.panel.config.tools.tabs.templates.layout_stacked" + ); + return html` + + `; + } + + // Reset/clear live in the editor toolbar next to the built-in undo/redo, + // copy, search and fullscreen buttons; the trailing divider separates them. + private _editorToolbarItems = memoizeOne( + ( + localize: HomeAssistant["localize"] + ): (HaIconButtonToolbarItem | string)[] => [ + { + id: "restore-demo", + label: localize("ui.panel.config.tools.tabs.templates.reset"), + path: mdiRestore, + action: () => this._restoreDemo(), + }, + { + id: "clear", + label: localize("ui.common.clear"), + path: mdiTrashCanOutline, + action: () => this._clear(), + }, + "divider", + ] + ); + + private _renderEditorCard() { + return html` + +
+ +
+ ${ + this.narrow + ? nothing + : html` + + ${this.hass.localize( + "ui.panel.config.tools.tabs.templates.keyboard_tip", + { + autocomplete: html`Ctrl+Space`, + } + )} + + ` + } +
+ `; + } + + private _renderResultCard(type: string, resultType: string) { + const showEmptyState = + !this._error && !this._rendering && !this._template?.trim(); + + return html` + +
+ ${ + this._rendering + ? html`` + : "" + } + ${ + this._error + ? html`${this._error}` + : nothing + } + ${ + showEmptyState + ? html`
+ ${this.hass.localize( + "ui.panel.config.tools.tabs.templates.result_placeholder" + )} +
` + : this._templateResult + ? html` + + ${this.hass.localize( + "ui.panel.config.tools.tabs.templates.result_type" + )}: + ${resultType} + +
 ${
   type === "object"
     ? JSON.stringify(this._templateResult.result, null, 2)
     : this._templateResult.result
 }
-

- ${this.hass.localize( - "ui.panel.config.tools.tabs.templates.result_type" - )}: - ${resultType} -

${ this._templateResult.listeners.time ? html` @@ -316,109 +410,179 @@ ${ )} ` : nothing - }` + } + ` : nothing - } -
-
-
+ } +
+ `; } - private _observeTipHeight() { - if (!this._editorTip || this._tipResizeObserver) { - return; - } - this._tipResizeObserver = new ResizeObserver((entries) => { - const height = - entries[0]?.borderBoxSize?.[0]?.blockSize ?? - entries[0]?.contentRect.height; - if (height) { - this.style.setProperty("--tip-height", `${height}px`); - } - }); - this._tipResizeObserver.observe(this._editorTip); + private _splitRepositioned(ev: Event) { + this._splitPosition = (ev.target as HaSplitPanel).position; + this._storeSplitPosition(); } - private _expandedChanged( - ev: HASSDomEvent - ) { - this._descriptionExpanded = ev.detail.expanded; + private _toggleOrientation() { + this._splitOrientation = + this._splitOrientation === "vertical" ? "horizontal" : "vertical"; + if (this._inited) { + localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation; + } } + private _storeSplitPosition = debounce( + () => { + if (!this._inited) { + return; + } + localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition); + }, + 500, + false + ); + static get styles(): CSSResultGroup { return [ haStyle, haStyleScrollbar, css` :host { - user-select: none; + display: flex; + flex-direction: column; + height: 100%; } - .content { - gap: var(--ha-space-4); + .about { + flex: none; padding: var(--ha-space-4); - } - - .content:has(ha-expansion-panel) { padding-bottom: 0; } - .content.horizontal { - --panel-header-height: calc( - var(--header-height) + 1em * 2 + var(--ha-line-height-normal) * - var(--ha-font-size-m) + 1px + 2px - ); - --description-pane-height: calc( - var(--ha-space-4) + 48px + - ( - var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 + - var(--ha-space-1) * 2 - ) * - var(--description-expanded) + var(--ha-card-border-width, 1px) * 2 - ); - --card-header-height: calc( - var(--ha-space-3) + var(--ha-space-4) + - var(--ha-line-height-expanded) * - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - ); - --card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px); - --tip-height-minimal: calc( - var(--mdc-icon-size, 24px) + var(--ha-space-4) - ); - --edit-pane-height: calc( - 100vh - var(--panel-header-height) - var( - --description-pane-height - ) - var(--ha-space-4) * - 2 - ); - --code-mirror-max-height: calc( - var(--edit-pane-height) - var(--card-header-height) + - var(--ha-space-2) - var(--card-actions-height) - var( - --tip-height, - var(--tip-height-minimal) - ) - var(--ha-space-4) - var(--ha-card-border-width, 1px) * - 2 - ); + .about a { + color: var(--primary-color); + } + + .panes { + flex: 1; + min-height: 0; + box-sizing: border-box; + padding: var(--ha-space-4); + --ha-split-panel-min: 20%; + --ha-split-panel-max: 80%; + --ha-split-panel-divider-hit-area: var(--ha-space-4); + } + + /* On wide viewports we slot our own handle (the orientation toggle) + into the divider, so hide the default grip. On narrow there is no + toggle, so keep the default grip as the resize affordance. */ + :host(:not([narrow])) .panes { + --ha-split-panel-grip-display: none; + } + + /* Orientation toggle that lives on the divider. Clicking it toggles + orientation; resizing is done by dragging the divider elsewhere. */ + .divider-toggle { + position: relative; + z-index: 1; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + border: 1px solid var(--divider-color); + border-radius: 50%; + background-color: var(--card-background-color); + color: var(--secondary-text-color); + cursor: pointer; + --mdc-icon-size: 16px; + transition: + color var(--ha-animation-duration-fast) ease-out, + border-color var(--ha-animation-duration-fast) ease-out; + } + + @media (hover: hover) { + .divider-toggle:hover { + color: var(--primary-color); + border-color: var(--primary-color); + } + } + + .divider-toggle:focus-visible { + outline: none; + color: var(--primary-color); + border-color: var(--primary-color); + } + + .pane { + display: flex; + min-width: 0; + height: 100%; + box-sizing: border-box; + contain: size; + } + + .pane[slot="start"] { + padding-inline-end: var(--ha-space-4); + } + + .pane[slot="end"] { + padding-inline-start: var(--ha-space-4); + } + + .panes.vertical .pane[slot="start"] { + padding-inline-end: 0; + padding-block-end: var(--ha-space-4); + } + + .panes.vertical .pane[slot="end"] { + padding-inline-start: 0; + padding-block-start: var(--ha-space-4); + } + + .pane ha-card { + flex: 1; + min-width: 0; } ha-card { - margin-bottom: var(--ha-space-4); + display: flex; + flex-direction: column; + height: 100%; + margin: 0; + } + + .edit-pane .card-content { + flex: 1; + min-height: 0; + display: flex; + } + + .edit-pane ha-code-editor { + flex: 1; + min-height: 0; + width: 100%; + --code-mirror-height: 100%; + } + + .render-pane .card-content { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--ha-space-2); } .edit-pane { direction: var(--direction); } - .edit-pane a { - color: var(--primary-color); - } - - .content.horizontal > * { - width: 50%; - margin-bottom: 0px; - } - .render-spinner { position: absolute; top: var(--ha-space-2); @@ -428,10 +592,24 @@ ${ } ha-alert { - margin-bottom: var(--ha-space-2); display: block; } + .render-pane ha-label { + align-self: flex-start; + } + + .empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 120px; + padding: var(--ha-space-4); + text-align: center; + color: var(--secondary-text-color); + } + .rendered { font-family: var(--ha-font-family-code); -webkit-font-smoothing: var(--ha-font-smoothing); @@ -439,6 +617,7 @@ ${ clear: both; white-space: pre-wrap; background-color: var(--secondary-background-color); + border-radius: var(--ha-border-radius-md); padding: var(--ha-space-2); margin-top: 0; margin-bottom: 0; @@ -447,7 +626,7 @@ ${ p, ul { - margin-block-end: 0; + margin-block: 0; } .description > p { margin-block-start: 0; @@ -468,26 +647,6 @@ ${ color: var(--secondary-text-color); } - .render-pane .card-content { - user-select: text; - } - - .content.horizontal .render-pane .card-content { - overflow: auto; - max-height: calc( - var(--code-mirror-max-height) + - 47px - var(--ha-card-border-radius, var(--ha-border-radius-lg)) - ); - } - - .content.horizontal .render-pane { - overflow: hidden; - padding-bottom: var( - --ha-card-border-radius, - var(--ha-border-radius-lg) - ); - } - .all_listeners { color: var(--warning-color); } @@ -507,19 +666,6 @@ ${ background-color: var(--secondary-background-color); white-space: nowrap; } - - @media all and (max-width: 870px) { - .content ha-card { - max-width: 100%; - } - } - - .card-actions { - display: flex; - } - .card-actions > ha-button:last-child { - margin-inline-start: auto; - } `, ]; } @@ -615,7 +761,7 @@ ${ if (!this._inited) { return; } - localStorage["panel-dev-template-template"] = this._template; + localStorage[STORAGE_KEY_TEMPLATE] = this._template; } private async _restoreDemo() { @@ -631,7 +777,7 @@ ${ } this._template = DEMO_TEMPLATE; this._subscribeTemplate(); - delete localStorage["panel-dev-template-template"]; + delete localStorage[STORAGE_KEY_TEMPLATE]; } private async _clear() { @@ -647,12 +793,8 @@ ${ } this._unsubscribeTemplate(); this._template = ""; - // Reset to empty result. Setting to 'undefined' results in a different visual - // behaviour compared to manually emptying the template input box. - this._templateResult = { - result: "", - listeners: { all: false, entities: [], domains: [], time: false }, - }; + // An empty template shows the placeholder empty state. + this._templateResult = undefined; } } diff --git a/src/translations/en.json b/src/translations/en.json index 59817fadcd..26b35e1560 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3955,6 +3955,9 @@ "about": "About templates", "editor": "Template editor", "result": "Result", + "result_placeholder": "Your template result will appear here.", + "layout_stacked": "Drag to resize, click for stacked view", + "layout_side_by_side": "Drag to resize, click for side-by-side view", "reset": "Reset to demo template", "confirm_reset": "Do you want to reset your current template back to the demo template?", "confirm_clear": "Do you want to clear your current template?", From 22e646a68aad20c055558976016cb0f8ab96ce7f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 30 Jul 2026 09:03:43 +0200 Subject: [PATCH 02/49] Allow area card name to wrap to two lines in vertical layout (#53370) * Allow area card name to wrap to two lines in vertical layout * Align vertical tile icons in grid sections * Cut single big word --- src/components/tile/ha-tile-container.ts | 19 +++++++++-- src/components/tile/ha-tile-info.ts | 34 +++++++++++++++++-- src/panels/lovelace/cards/hui-area-card.ts | 11 ++++++ .../lovelace/cards/hui-shortcut-card.ts | 6 ++++ src/panels/lovelace/cards/hui-tile-card.ts | 6 ++++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/components/tile/ha-tile-container.ts b/src/components/tile/ha-tile-container.ts index 73c24cd6a4..196af16a8c 100644 --- a/src/components/tile/ha-tile-container.ts +++ b/src/components/tile/ha-tile-container.ts @@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement { @property({ type: Boolean }) public vertical = false; + /* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */ + @property({ type: Boolean, attribute: "fixed-info-height" }) + public fixedInfoHeight = false; + @property({ attribute: false }) public interactive = false; @@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement { protected render() { const containerOrientationClass = this.featurePosition === "inline" ? "horizontal" : ""; - const contentClasses = { vertical: this.vertical }; + const contentClasses = { + vertical: this.vertical, + "fixed-info-height": this.fixedInfoHeight, + }; return html`
${ @@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { @@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { @@ -818,6 +825,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { justify-content: center; color: white; } + ha-tile-info.twoline { + --ha-tile-info-primary-line-clamp: 2; + --ha-tile-info-primary-line-height: var(--ha-space-4); + } `, ]; } diff --git a/src/panels/lovelace/cards/hui-shortcut-card.ts b/src/panels/lovelace/cards/hui-shortcut-card.ts index 6d07de4cda..0b3c147652 100644 --- a/src/panels/lovelace/cards/hui-shortcut-card.ts +++ b/src/panels/lovelace/cards/hui-shortcut-card.ts @@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) public layout?: string; + @state() private _config?: ShortcutCardConfig; private _navInfo = new NavigationPathInfoController(this); @@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard { const style = color ? { "--tile-color": color } : {}; + const fixedInfoHeight = + this.layout === "grid" && this._config.grid_options?.rows !== "auto"; + return html` Date: Wed, 29 Jul 2026 15:33:02 +0200 Subject: [PATCH 03/49] Strip empty strings from http config (#53377) --- src/panels/config/network/ha-config-http-form.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/panels/config/network/ha-config-http-form.ts b/src/panels/config/network/ha-config-http-form.ts index 25009c0fe8..f1fa04e9ec 100644 --- a/src/panels/config/network/ha-config-http-form.ts +++ b/src/panels/config/network/ha-config-http-form.ts @@ -397,8 +397,20 @@ class HaConfigHttpForm extends LitElement { this._error = undefined; this._fieldErrors = {}; this._showNoChanges = false; + // Drop empty entries from multi-value fields, and omit the field entirely + // once it is empty so the backend applies its default. Otherwise a cleared + // "IP address to bind to" would submit [""] / [], which binds to nothing. + const config = Object.fromEntries( + Object.entries(this._config).map(([key, value]) => { + if (Array.isArray(value)) { + const filtered = value.filter(Boolean); + return [key, filtered.length ? filtered : undefined]; + } + return [key, value]; + }) + ) as HttpConfig; try { - const result = await saveHttpConfig(this.hass, this._config); + const result = await saveHttpConfig(this.hass, config); if (!result.restart) { this._showNoChanges = true; } From d08d8dde64bb7e6b44221a8e1240f87922ed8bea Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 29 Jul 2026 19:24:39 +0200 Subject: [PATCH 04/49] Show integration logo and name in the replace device dialog (#53384) --- .../device/dialog-device-replaced.ts | 90 ++++++++++++++----- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/src/components/device/dialog-device-replaced.ts b/src/components/device/dialog-device-replaced.ts index dd4caa5b4a..d83ce97716 100644 --- a/src/components/device/dialog-device-replaced.ts +++ b/src/components/device/dialog-device-replaced.ts @@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event"; import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceName } from "../../common/entity/compute_device_name"; import { getDeviceArea } from "../../common/entity/context/get_device_context"; +import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; +import { domainToName } from "../../data/integration"; import type { HomeAssistant } from "../../types"; import type { HassDialog } from "../../dialogs/make-dialog-manager"; +import { brandsUrl } from "../../util/brands-url"; import "../ha-dialog"; import "../ha-svg-icon"; import "../item/ha-list-item-button"; @@ -23,11 +26,21 @@ export class DialogDeviceReplaced @state() private _open = false; + @state() private _configEntryLookup?: Record; + @property({ attribute: false }) public hass!: HomeAssistant; public async showDialog(params: DeviceReplacedDialogParams): Promise { this._params = params; this._open = true; + this._loadConfigEntries(); + } + + private async _loadConfigEntries(): Promise { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); } public closeDialog(): boolean { @@ -55,15 +68,23 @@ export class DialogDeviceReplaced candidates: string[], primaryId: string | null, devices: HomeAssistant["devices"], - areas: HomeAssistant["areas"] + areas: HomeAssistant["areas"], + configEntryLookup: Record | undefined ) => candidates.map((deviceId) => { const device = devices[deviceId]; const area = device ? getDeviceArea(device, areas) : undefined; + const configEntry = device?.primary_config_entry + ? configEntryLookup?.[device.primary_config_entry] + : undefined; return { deviceId, name: device ? computeDeviceName(device) : deviceId, - secondary: area ? computeAreaName(area) : undefined, + area: area ? computeAreaName(area) : undefined, + domain: configEntry?.domain, + domainName: configEntry + ? domainToName(this.hass.localize, configEntry.domain) + : undefined, isPrimary: deviceId === primaryId, }; }) @@ -92,31 +113,54 @@ export class DialogDeviceReplaced this._params.candidates, this._params.primaryId, this.hass.devices, - this.hass.areas - ).map( - (item) => html` + this.hass.areas, + this._configEntryLookup + ).map((item) => { + const supportingText = [ + item.area, + item.domainName, + item.isPrimary + ? this.hass.localize( + "ui.components.device-picker.replaced_dialog.recommended" + ) + : undefined, + ] + .filter(Boolean) + .join(" • "); + return html` - + ${ + item.domain + ? html`` + : html`` + } ${item.name} ${ - item.secondary || item.isPrimary - ? html` - ${[ - item.secondary, - item.isPrimary - ? this.hass.localize( - "ui.components.device-picker.replaced_dialog.recommended" - ) - : undefined, - ] - .filter(Boolean) - .join(" • ")} - ` + supportingText + ? html`${supportingText}` : nothing } - ` - )} + `; + })} `; @@ -132,6 +176,10 @@ export class DialogDeviceReplaced padding: 0 var(--ha-space-6) var(--ha-space-4); color: var(--secondary-text-color); } + img[slot="start"] { + width: 24px; + height: 24px; + } `; } From ff814167c68fb68c5d8e5d9c2c2962c289952cd6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jul 2026 10:41:32 +0200 Subject: [PATCH 05/49] Bumped version to 20260729.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7f98b63a19..c8c2afdd71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20260729.0" +version = "20260729.1" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From 7edb9f81648247802033a70c2fc6fe22ef42ca4f Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 30 Jul 2026 11:13:49 +0100 Subject: [PATCH 06/49] Launch screen tweaks (#53380) * Polish the launch screen - Use the system sans-serif font so the launch screen no longer blocks on loading Roboto - Reduce the gap between the logo and the loading text - Dim the loading text to 66% opacity - Match the OHF logo variant to the applied theme instead of the system color scheme - Make the launch screen text and buttons unselectable Co-Authored-By: Claude Opus 5 (1M context) * Drop unit test * Use classMap * Drop view transition for css transition to avoid flash --------- Co-authored-by: marcinbauer85 Co-authored-by: Claude Opus 5 (1M context) --- demo/src/html/index.html.template | 14 +++-------- src/html/index.html.template | 30 +++------------------- src/layouts/ha-init-page.ts | 8 ++++-- src/state/themes-mixin.ts | 3 +++ src/translations/en.json | 2 +- src/util/launch-screen.ts | 42 ++++++++++++++++++------------- 6 files changed, 42 insertions(+), 57 deletions(-) diff --git a/demo/src/html/index.html.template b/demo/src/html/index.html.template index ac8007f6fd..36c1e39272 100644 --- a/demo/src/html/index.html.template +++ b/demo/src/html/index.html.template @@ -34,16 +34,8 @@ content="width=device-width, initial-scale=1, shrink-to-fit=no" /> - <%= renderTemplate("_social_meta.html.template") %>