Compare commits

...

7 Commits

Author SHA1 Message Date
Paul Bottein
3046f3e47d 20260429.1 (#51817) 2026-04-30 20:33:33 +02:00
Paul Bottein
35601a0900 Bumped version to 20260429.1 2026-04-30 20:32:28 +02:00
Wendelin
e7016c15af Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:32:08 +02:00
Wendelin
624521e30b Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 20:32:08 +02:00
Bram Kragten
4876bfa639 Add tooltips for Jinja editors (#51792)
* Add descriptions to Jinja2 tags, filters, expressions, tests and variables

All standard Jinja2 tags, filters, and expression completions now carry
info and detail strings so the autocomplete info popover shows meaningful
documentation when users browse them — not just HA-specific functions.

* Add keyboard shortcut tip to the template developer tool

A ha-tip below the editor card now shows users that Ctrl+Space triggers
autocomplete, Ctrl+F opens the search panel, and F11 toggles fullscreen,
making the editor's built-in features more discoverable.

* Add hover tooltips for Jinja2 functions, filters and expressions

Hovering over a function, filter, tag, test, or variable name inside a
Jinja2 template shows a tooltip with its signature and description.
Non-tag completions also get a help-circle icon linking to the
corresponding Home Assistant template-functions documentation page.

The tooltip is rendered as a custom Lit element (ha-code-editor-jinja-hover)
that takes the Completion object and an optional docUrl as properties.

The tooltip source (haJinjaHoverSource) is wired into ha-code-editor
via CodeMirror's hoverTooltip extension. The documentationUrl() helper
is used so the link points to the correct subdomain (www / rc / next)
based on the running HA version.

* Add hover tooltips for Jinja2 hover + arg value tooltips for entity/device/area

Wire haJinjaHoverSource into ha-code-editor via CodeMirror hoverTooltip.
Two types of hover are now shown in jinja2/yaml mode:

- Hovering a function/filter/tag/expression name shows its signature,
  description, and a doc-link icon (non-tags only).
- Hovering a string-literal argument of a known HA Jinja function (e.g.
  states(), device_name(), area_entities()) shows the friendly name,
  current state, device, and area for entity_id arguments; the device
  name and area for device_id arguments; and the area name for area_id
  arguments. The same applies to states["entity_id"] subscripts.

The arg-value tooltip reuses CompletionItem / ha-code-editor-completion-items
(the same component used for autocomplete info popovers) via a new
ha-code-editor-jinja-arg-hover element. HA registry data is passed from
ha-code-editor via a HassArgHoverContext interface to keep jinja_ha_completions.ts
free of HomeAssistant type imports.

* only add tip for autocomplete

* review
2026-04-30 20:32:07 +02:00
AlCalzone
5dea0764b2 Expose Z-Wave exclusion instructions when removing device (#51788)
* Expose Z-Wave exclusion instructions when removing device

* text tweaks

* Apply suggestion from @MindFreeze

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* bring back comment

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 20:32:05 +02:00
Paul Bottein
121ed7ac1f 20260429.0 (#51790) 2026-04-29 16:47:32 +02:00
11 changed files with 1835 additions and 206 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260429.0"
version = "20260429.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -0,0 +1,42 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-code-editor-completion-items";
@customElement("ha-code-editor-jinja-arg-hover")
export class HaCodeEditorJinjaArgHover extends LitElement {
/** Bold heading shown above the items grid (e.g. entity/device/area name). */
@property({ attribute: false }) public heading?: string;
@property({ attribute: false }) public items: CompletionItem[] = [];
render() {
return html`
${this.heading
? html`<div class="heading">${this.heading}</div>`
: nothing}
<ha-code-editor-completion-items
.items=${this.items}
></ha-code-editor-completion-items>
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 360px;
}
.heading {
font-weight: var(--ha-font-weight-bold);
margin-bottom: 4px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-jinja-arg-hover": HaCodeEditorJinjaArgHover;
}
}

View File

@@ -0,0 +1,101 @@
import type { Completion } from "@codemirror/autocomplete";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiHelpCircleOutline } from "@mdi/js";
import "./ha-svg-icon";
@customElement("ha-code-editor-jinja-hover")
export class HaCodeEditorJinjaHover extends LitElement {
@property({ attribute: false }) public completion!: Completion;
@property({ attribute: false }) public docUrl?: string;
@property({ attribute: false }) public openDocumentation =
"Open documentation";
render() {
const info =
typeof this.completion.info === "string"
? this.completion.info
: undefined;
return html`
<div class="header">
<div class="sig">
<strong>${this.completion.label}</strong>
${this.completion.detail
? html`<span class="detail">(${this.completion.detail})</span>`
: nothing}
</div>
${this.docUrl
? html`<a
class="doc-link"
href=${this.docUrl}
target="_blank"
rel="noreferrer"
title=${this.openDocumentation}
><ha-svg-icon .path=${mdiHelpCircleOutline}></ha-svg-icon
></a>`
: nothing}
</div>
${info ? html`<div class="desc">${info}</div>` : nothing}
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 360px;
line-height: 1.5;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.sig {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
flex: 1;
min-width: 0;
}
.detail {
color: var(--secondary-text-color);
}
.doc-link {
flex-shrink: 0;
display: inline-flex;
align-items: center;
color: var(--secondary-text-color);
opacity: 0.7;
line-height: 1;
}
.doc-link:hover {
opacity: 1;
color: var(--primary-color);
}
.doc-link ha-svg-icon {
width: 16px;
height: 16px;
}
.desc {
font-size: 0.9em;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-jinja-hover": HaCodeEditorJinjaHover;
}
}

View File

@@ -36,9 +36,13 @@ import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import type { JinjaArgType } from "../resources/jinja_ha_completions";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
import { labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import "./ha-code-editor-completion-items";
@@ -326,6 +330,16 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.tooltips({
position: "absolute",
}),
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this._loadedCodeMirror!.haJinjaHoverSource(
view,
pos,
this.hass ? documentationUrl(this.hass, "") : undefined,
this.hass ? this._hassArgHoverContext() : undefined
),
{ hoverTime: 300 }
),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -575,6 +589,48 @@ export class HaCodeEditor extends ReactiveElement {
}
};
/**
* Builds a HassArgHoverContext from the current hass object so that
* haJinjaHoverSource can resolve entity / device / area friendly names
* without importing the full HomeAssistant type into the resource file.
*/
private _hassArgHoverContext(): HassArgHoverContext {
const hass = this.hass!;
const labelMap: Record<
string,
{ name: string; description?: string | null }
> = {};
for (const label of this._labels ?? []) {
labelMap[label.label_id] = {
name: label.name,
description: label.description,
};
}
return {
states: hass.states as HassArgHoverContext["states"],
devices: hass.devices as HassArgHoverContext["devices"],
areas: hass.areas as HassArgHoverContext["areas"],
floors: hass.floors as HassArgHoverContext["floors"],
entities: hass.entities as HassArgHoverContext["entities"],
labels: labelMap,
formatEntityState: (entityId) =>
hass.formatEntityState(hass.states[entityId]),
formatEntityName: (entityId) => {
const stateObj = hass.states[entityId];
return (
(stateObj?.attributes.friendly_name as string | undefined) ??
hass.entities[entityId]?.name ??
undefined
);
},
formatAttributeName: (entityId, attribute) =>
hass.formatEntityAttributeName(hass.states[entityId], attribute),
formatAttributeValue: (entityId, attribute) =>
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
localize: (key) => hass.localize(key as never),
};
}
private _renderInfo = (completion: Completion): CompletionInfo => {
const key =
typeof completion.apply === "string"

View File

@@ -59,7 +59,7 @@ export class HaSelect extends LitElement {
value: string | number | undefined
) => {
// just in case value is a number, convert it to string to avoid falsy value
const valueStr = String(value);
const valueStr = value !== undefined ? String(value) : undefined;
if (!options || !valueStr) {
return valueStr;
}

View File

@@ -36,6 +36,7 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types";
import { isMobileClient } from "../util/is_mobile";
import "./animation/ha-fade-in";
import "./ha-icon";
import "./ha-icon-button";
@@ -579,6 +580,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
private _renderToolTip(id: string, text: string) {
if (isMobileClient) {
return nothing;
}
return html`<ha-tooltip
for=${id}
show-delay="0"

View File

@@ -9,6 +9,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-spinner";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
@@ -158,6 +159,14 @@ class HaPanelDevTemplate extends LitElement {
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip .hass=${this.hass}>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
<ha-card
@@ -361,6 +370,22 @@ ${type === "object"
color: var(--warning-color);
}
ha-tip {
padding: 0 var(--ha-space-4) var(--ha-space-4);
display: block;
}
kbd {
display: inline-block;
font-family: var(--ha-font-family-code);
font-size: 0.85em;
padding: 1px 5px;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-xs);
background-color: var(--secondary-background-color);
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;

View File

@@ -8,15 +8,18 @@ import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-markdown";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-dialog";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import {
fetchZwaveNodeMetadata,
fetchZwaveNodeStatus,
NodeStatus,
removeFailedZwaveNode,
} from "../../../../../data/zwave_js";
import type { ZwaveJSNodeMetadata } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node";
@@ -51,6 +54,8 @@ class DialogZWaveJSRemoveNode extends LitElement {
@state() private _node?: ZWaveJSRemovedNode;
@state() private _metadata?: ZwaveJSNodeMetadata;
@state() private _onClose?: () => void;
private _removeNodeTimeoutHandle?: number;
@@ -72,12 +77,26 @@ class DialogZWaveJSRemoveNode extends LitElement {
this._entryId = params.entryId;
this._deviceId = params.deviceId;
this._onClose = params.onClose;
this._metadata = undefined;
this._open = true;
if (this._deviceId) {
const nodeStatus = await fetchZwaveNodeStatus(this.hass, this._deviceId!);
this._device = this.hass.devices[this._deviceId];
this._step =
nodeStatus.status === NodeStatus.Dead ? "start_removal" : "start";
if (nodeStatus.status !== NodeStatus.Dead) {
const requestedDeviceId = this._deviceId;
fetchZwaveNodeMetadata(this.hass, requestedDeviceId).then(
(metadata) => {
if (this._deviceId === requestedDeviceId) {
this._metadata = metadata;
}
},
() => {
// instructions are supplemental — ignore fetch errors
}
);
}
} else if (params.skipConfirmation) {
this._startExclusion();
} else {
@@ -170,13 +189,39 @@ class DialogZWaveJSRemoveNode extends LitElement {
`;
}
if (["exclusion", "remove"].includes(this._step)) {
if (this._step === "exclusion") {
const exclusion = this._metadata?.exclusion?.trim();
return html`
<ha-spinner></ha-spinner>
<div>
<p>
<b
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.ready_to_remove"
)}</b
>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.trigger_device_exclusion"
)}
</p>
${exclusion
? html`<ha-markdown breaks .content=${exclusion}></ha-markdown>`
: html`<p>
${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.follow_device_instructions"
)}
</p>`}
</div>
`;
}
if (this._step === "remove") {
return html`
<ha-spinner></ha-spinner>
<div>
<p>
${this.hass.localize(
`ui.panel.config.zwave_js.remove_node.${this._step === "exclusion" ? "follow_device_instructions" : "removing_device"}`
"ui.panel.config.zwave_js.remove_node.removing_device"
)}
</p>
</div>
@@ -343,6 +388,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
public handleDialogClosed(): void {
this._unsubscribe();
this._entryId = undefined;
this._metadata = undefined;
this._step = "start";
this._open = false;
if (this._onClose) {
@@ -371,6 +417,10 @@ class DialogZWaveJSRemoveNode extends LitElement {
color: var(--secondary-text-color);
}
.content ha-markdown {
color: var(--secondary-text-color);
}
ha-alert {
width: 100%;
}

View File

@@ -43,6 +43,7 @@ export {
drawSelection,
EditorView,
highlightActiveLine,
hoverTooltip,
keymap,
lineNumbers,
rectangularSelection,
@@ -70,9 +71,10 @@ export const closeBracketsOverride = Prec.highest(
export {
haJinjaCompletionSource,
haJinjaHoverSource,
JINJA_FUNCTION_ARG_TYPES,
} from "./jinja_ha_completions";
export type { JinjaArgType } from "./jinja_ha_completions";
export type { HassArgHoverContext, JinjaArgType } from "./jinja_ha_completions";
export { closePercentBrace };
export const langCompartment = new Compartment();

File diff suppressed because it is too large Load Diff

View File

@@ -1497,6 +1497,9 @@
"area_label": "Area",
"description": "Configure which areas correspond to each vacuum segment"
},
"codemirror": {
"open_documentation": "Open documentation"
},
"safe_mode": {
"title": "Safe mode",
"text": "Home Assistant is running in safe mode, custom integrations and community frontend modules are not available. Restart Home Assistant to exit safe mode."
@@ -3867,7 +3870,8 @@
"no_listeners": "This template does not listen for any events and will not update automatically.",
"listeners": "This template listens for the following state changed events:",
"entity": "Entity",
"domain": "Domain"
"domain": "Domain",
"keyboard_tip": "Press {autocomplete} to trigger autocomplete, when your cursor is inside a function that supports it."
},
"statistics": {
"title": "Statistics",
@@ -7702,7 +7706,9 @@
"menu_remove_device": "Force-remove an unavailable device",
"start_exclusion": "Start exclusion",
"cancel_exclusion": "Cancel exclusion",
"follow_device_instructions": "Follow the directions that came with your device to trigger exclusion on the device.",
"ready_to_remove": "Ready to remove device.",
"follow_device_instructions": "Follow the directions that came with your device.",
"trigger_device_exclusion": "To trigger exclusion on the device:",
"removing_device": "Removing device",
"exclusion_failed": "An error occurred. Please check the logs for more information.",
"exclusion_finished": "Device {id} has been removed from your Z-Wave network."