Compare commits

...
Author SHA1 Message Date
Petar Petrov ab3f294bb7 Fix frozen solar-to-grid dot and phantom flows in energy distribution card 2026-09-09 17:30:55 +03:00
f942a5afe5 Add visibility condition reordering (#53252)
Co-authored-by: Petar Petrov <[email protected]>
2026-09-09 14:49:01 +02:00
89b745b851 Add contextual descriptions to rows in card condition editor (#52817)
* Add row targets UI to entity state and numeric state in card condition editor

* Use automation descriptions instead of target ui

* Type check

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Format condition editor

* Fix contextual condition descriptions

* Resolve state references in condition descriptions

* Share automation condition headers with visibility conditions

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-09 15:29:42 +03:00
7 changed files with 737 additions and 194 deletions
@@ -0,0 +1,154 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-behavior";
import "../../panels/config/automation/ha-automation-row-options";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ attribute: false }) public isNew = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
protected render() {
const descriptionHasTarget = "target" in (this.description || {});
const hasEntityTarget =
this.condition?.condition === "state" ||
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
if (descriptionHasTarget && "target" in this.condition) {
target = this.condition.target;
} else if (
hasEntityTarget &&
"entity_id" in this.condition &&
this.condition.entity_id
) {
target = this._getEntityTarget(this.condition.entity_id);
} else if ("device_id" in this.condition && this.condition.device_id) {
target = this._getDeviceTarget(this.condition.device_id);
}
}
return html`
<h3>
${this.label}
${
this.description && this.condition
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${
this.description?.target
? { target: this.description.target }
: undefined
}
.interactive=${this.condition?.condition !== "device"}
></ha-automation-row-targets>`
: nothing
}
${
this.description && this.condition
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
note
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
role="img"
.path=${mdiCommentTextOutline}
aria-label=${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${truncateWithEllipsis(note, 250)}</p></ha-tooltip
>
`
: nothing
}
</h3>
`;
}
static styles = css`
:host {
display: block;
min-width: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
ha-tooltip {
cursor: default;
}
ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-summary": HaAutomationConditionSummary;
}
}
@@ -5,7 +5,6 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiCommentEditOutline,
mdiCommentTextOutline,
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
@@ -19,7 +18,6 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -32,10 +30,10 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-condition-summary";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-row-event-chip";
@@ -64,7 +62,6 @@ import {
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
showAlertDialog,
showPromptDialog,
@@ -73,12 +70,7 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
import "./types/ha-automation-condition-and";
@@ -181,26 +173,6 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
250
);
return html`
${
this.optionsInSidebar && this.condition.condition !== "trigger"
@@ -226,56 +198,15 @@ export default class HaAutomationConditionRow extends LitElement {
></ha-condition-icon>
</div>`
}
<h3 slot="header">
${capitalizeFirstLetter(
<ha-automation-condition-summary
slot="header"
.label=${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? this._renderTargets(
target,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
>
`
: nothing
}
</h3>
.condition=${this.condition}
.description=${this.conditionDescriptions[this.condition.condition]}
.isNew=${this._isNew}
></ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this._testing}
.variant=${this._testingResult ? "success" : "warning"}
@@ -621,45 +552,6 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
@@ -36,9 +36,14 @@ import { hasConfigChanged } from "../../common/has-changed";
import type { LovelaceCard } from "../../types";
import type { EnergyDistributionCardConfig } from "../types";
import { formatNumber } from "../../../../common/number/format_number";
import { round } from "../../../../common/number/round";
const CIRCLE_CIRCUMFERENCE = 238.76104;
// Flows are differences of sums; anything that rounds to 0 Wh is noise
const hasFlow = (value: number | null): value is number =>
round(value ?? 0, 3) > 0;
const periodIncludesNow = (data: EnergyData): boolean =>
!data.end || data.end.getTime() >= Date.now();
@@ -843,8 +848,8 @@ class HuiEnergyDistrubutionCard
? svg`<path
id="battery-grid"
class=${classMap({
"battery-from-grid": Boolean(batteryFromGrid),
"battery-to-grid": Boolean(batteryToGrid),
"battery-from-grid": hasFlow(batteryFromGrid),
"battery-to-grid": hasFlow(batteryToGrid),
})}
d="M45,100 v-15 c0,-35 -10,-30 -30,-30 h-20"
vector-effect="non-scaling-stroke"
@@ -875,14 +880,14 @@ class HuiEnergyDistrubutionCard
: nothing
}
${
solarToGrid && this._animate
hasFlow(solarToGrid) && this._animate
? svg`<circle
r="1"
class="return"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (solarToGrid / totalLines) * 6}s"
dur="${6 - (solarToGrid / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
@@ -892,7 +897,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
solarConsumption && this._animate
hasFlow(solarConsumption) && this._animate
? svg`<circle
r="1"
class="solar"
@@ -909,7 +914,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
gridConsumption && this._animate
hasFlow(gridConsumption) && this._animate
? svg`<circle
r="1"
class="grid"
@@ -926,7 +931,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
solarToBattery && this._animate
hasFlow(solarToBattery) && this._animate
? svg`<circle
r="1"
class="battery-solar"
@@ -943,7 +948,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryConsumption && this._animate
hasFlow(batteryConsumption) && this._animate
? svg`<circle
r="1"
class="battery-house"
@@ -960,7 +965,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryFromGrid && this._animate
hasFlow(batteryFromGrid) && this._animate
? svg`<circle
r="1"
class="battery-from-grid"
@@ -978,7 +983,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryToGrid && this._animate
hasFlow(batteryToGrid) && this._animate
? svg`<circle
r="1"
class="battery-to-grid"
@@ -492,8 +492,8 @@ export class HuiDialogEditCard
max-width: var(--ha-view-sections-column-max-width, 500px);
}
.content .element-editor {
padding-inline-start: var(--ha-space-1);
padding-inline-end: var(--ha-space-2);
margin-inline-start: var(--ha-space-1);
margin-bottom: 0;
}
@@ -7,6 +7,8 @@ import {
mdiDelete,
mdiDotsVertical,
mdiFlask,
mdiArrowDown,
mdiArrowUp,
mdiPlaylistEdit,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
@@ -24,7 +26,9 @@ import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/automation/ha-automation-condition-summary";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/automation/ha-automation-row-live-test";
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
@@ -55,6 +59,13 @@ import {
CONDITION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import type { ConditionDescriptions } from "../../../../data/condition";
import {
conditionDescriptionsContext,
fullEntitiesContext,
} from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
AndCondition,
@@ -219,6 +230,22 @@ export class HaCardConditionEditor extends LitElement {
@property({ attribute: false }) condition!: VisibilityCondition;
@property({ type: Number }) public index = 0;
@property({ type: Boolean }) public first = false;
@property({ type: Boolean }) public last = false;
@property({ attribute: false }) public sortableData?: VisibilityCondition;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@@ -433,6 +460,16 @@ export class HaCardConditionEditor extends LitElement {
const hideLiveTest = this._hideLiveTest(condition);
const summaryCondition =
condition.condition === "time"
? { ...condition, weekday: condition.weekdays }
: this._usesAutomationEditor ||
CONTAINER_CONDITIONS.includes(condition.condition) ||
(!isNoEntityCondition(condition.condition, this._noEntity) &&
condition.condition in this._conditionDescriptions)
? condition
: undefined;
return html`
<div class="container">
<ha-expansion-panel left-chevron>
@@ -462,13 +499,28 @@ export class HaCardConditionEditor extends LitElement {
>`
: nothing
}
<h3 slot="header">
${
this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
) || condition.condition
<ha-automation-condition-summary
slot="header"
.condition=${summaryCondition}
.description=${
summaryCondition
? this._conditionDescriptions[condition.condition]
: undefined
}
</h3>
.label=${
summaryCondition
? capitalizeFirstLetter(
describeCondition(
summaryCondition,
this.hass,
this._entityReg
)
)
: this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
) || condition.condition
}
></ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this._testingResult !== undefined}
.variant=${this._testingResult ? "success" : "warning"}
@@ -486,6 +538,7 @@ export class HaCardConditionEditor extends LitElement {
)
}
</ha-automation-row-event-chip>
<slot name="drag-handle" slot="icons"></slot>
<ha-dropdown
slot="icons"
@wa-select=${this._handleAction}
@@ -530,6 +583,18 @@ export class HaCardConditionEditor extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="move_up" .disabled=${this.first}>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="icon" .path=${mdiArrowUp}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="move_down" .disabled=${this.last}>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="icon" .path=${mdiArrowDown}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item
value="toggle_yaml"
.disabled=${!this._uiAvailable}
@@ -632,6 +697,12 @@ export class HaCardConditionEditor extends LitElement {
case "cut":
this._cutCondition();
return;
case "move_up":
fireEvent(this, "move-up");
return;
case "move_down":
fireEvent(this, "move-down");
return;
case "toggle_yaml":
this._yamlMode = !this._yamlMode;
return;
@@ -714,11 +785,6 @@ export class HaCardConditionEditor extends LitElement {
color: var(--secondary-text-color);
opacity: 0.9;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
.content {
padding: 12px;
}
@@ -1,17 +1,24 @@
import { consume } from "@lit/context";
import { mdiContentPaste, mdiPlus } from "@mdi/js";
import { mdiContentPaste, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { storage } from "../../../../common/decorators/storage";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { nextRender } from "../../../../common/util/render-status";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
Condition,
@@ -78,6 +85,20 @@ export class HaCardConditionsEditor extends LitElement {
private _focusLastConditionOnChange = false;
@state() private _rowSortSelected?: number;
private _conditionKeys = new WeakMap<VisibilityCondition, number>();
private _nextConditionKey = 0;
private _getKey(condition: VisibilityCondition): number {
if (!this._conditionKeys.has(condition)) {
this._conditionKeys.set(condition, this._nextConditionKey++);
}
return this._conditionKeys.get(condition)!;
}
protected firstUpdated() {
// Automation condition editors read labels from the config fragment.
this.hass.loadFragmentTranslation("config");
@@ -128,62 +149,233 @@ export class HaCardConditionsEditor extends LitElement {
protected render() {
return html`
<div class="conditions">
${this.conditions.map(
(cond, idx) => html`
<ha-card-condition-editor
.index=${idx}
@duplicate-condition=${this._duplicateCondition}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.condition=${cond}
></ha-card-condition-editor>
`
)}
<div>
<ha-dropdown @wa-select=${this._addCondition}>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.add"
<ha-sortable
handle-selector=".handle"
draggable-selector="ha-card-condition-editor"
group="lovelace-conditions"
invert-swap
@item-moved=${this._conditionMoved}
@item-added=${this._conditionAdded}
@item-removed=${this._conditionRemoved}
>
<div class="conditions">
${repeat(
this.conditions,
(condition) => this._getKey(condition),
(cond, idx) => html`
<ha-card-condition-editor
.sortableData=${cond}
.index=${idx}
.first=${idx === 0}
.last=${idx === this.conditions.length - 1}
@duplicate-condition=${this._duplicateCondition}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.condition=${cond}
>
<div
slot="drag-handle"
class="handle ${
this._rowSortSelected === idx ? "active" : ""
}"
role="button"
tabindex="0"
aria-label=${this.hass.localize("ui.common.move")}
aria-pressed=${this._rowSortSelected === idx}
.index=${idx}
@click=${this._handleDragClick}
@keydown=${this._handleDragKeydown}
>
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
</ha-card-condition-editor>
`
)}
<div>
<ha-dropdown @wa-select=${this._addCondition}>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.add"
)}
</ha-button>
${
this._canPaste
? html`
<ha-dropdown-item value="paste">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.paste_condition"
)}
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
</ha-dropdown-item>
`
: nothing
}
${this._availableConditions.map(
(condition) => html`
<ha-dropdown-item .value=${condition}>
${
this.hass!.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition}.label`
) || condition
}
<ha-svg-icon
slot="icon"
.path=${ICON_CONDITION[condition]}
></ha-svg-icon>
</ha-dropdown-item>
`
)}
</ha-button>
${
this._canPaste
? html`
<ha-dropdown-item value="paste">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.paste_condition"
)}
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
</ha-dropdown-item>
`
: nothing
}
${this._availableConditions.map(
(condition) => html`
<ha-dropdown-item .value=${condition}>
${
this.hass!.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition}.label`
) || condition
}
<ha-svg-icon
slot="icon"
.path=${ICON_CONDITION[condition]}
></ha-svg-icon>
</ha-dropdown-item>
`
)}
</ha-dropdown>
</ha-dropdown>
</div>
</div>
</div>
</ha-sortable>
`;
}
private _move(oldIndex: number, newIndex: number) {
const conditions = [...this.conditions];
const condition = conditions.splice(oldIndex, 1)[0];
conditions.splice(newIndex, 0, condition);
this.conditions = conditions;
if (this._rowSortSelected === oldIndex) {
this._rowSortSelected = newIndex;
} else if (
this._rowSortSelected !== undefined &&
oldIndex < this._rowSortSelected &&
newIndex >= this._rowSortSelected
) {
this._rowSortSelected--;
} else if (
this._rowSortSelected !== undefined &&
oldIndex > this._rowSortSelected &&
newIndex <= this._rowSortSelected
) {
this._rowSortSelected++;
}
fireEvent(this, "value-changed", { value: conditions });
}
private _conditionMoved(ev: CustomEvent) {
ev.stopPropagation();
this._move(ev.detail.oldIndex, ev.detail.newIndex);
}
private async _conditionAdded(ev: CustomEvent) {
ev.stopPropagation();
const { index, data } = ev.detail;
if (this._rowSortSelected !== undefined && index <= this._rowSortSelected) {
this._rowSortSelected++;
}
let conditions = [...this.conditions];
conditions.splice(index, 0, data);
this.conditions = conditions;
await nextRender();
if (this.conditions !== conditions && !this.conditions.includes(data)) {
conditions = [...this.conditions];
conditions.splice(index, 0, data);
} else {
conditions = this.conditions;
}
fireEvent(this, "value-changed", { value: conditions });
}
private async _conditionRemoved(ev: CustomEvent) {
ev.stopPropagation();
const { index: removedIndex } = ev.detail;
const removed = this.conditions[removedIndex];
if (this._rowSortSelected === removedIndex) {
this._rowSortSelected = undefined;
} else if (
this._rowSortSelected !== undefined &&
removedIndex < this._rowSortSelected
) {
this._rowSortSelected--;
}
let conditions = [...this.conditions];
conditions.splice(removedIndex, 1);
this.conditions = conditions;
await nextRender();
if (this.conditions !== conditions) {
conditions = [...this.conditions];
const index = conditions.indexOf(removed);
if (index !== -1) {
conditions.splice(index, 1);
}
}
fireEvent(this, "value-changed", { value: conditions });
}
private _moveUp(ev: CustomEvent) {
ev.stopPropagation();
const row = ev.currentTarget as HaCardConditionEditor;
if (!row.first) {
this._move(row.index, row.index - 1);
}
}
private _moveDown(ev: CustomEvent) {
ev.stopPropagation();
const row = ev.currentTarget as HaCardConditionEditor;
if (!row.last) {
this._move(row.index, row.index + 1);
}
}
private _handleDragClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { index: number }>
) {
ev.stopPropagation();
this._rowSortSelected =
this._rowSortSelected === ev.currentTarget.index
? undefined
: ev.currentTarget.index;
}
private _handleDragKeydown(
ev: KeyboardEvent &
HASSDomCurrentTargetEvent<HTMLElement & { index: number }>
) {
const handle = ev.currentTarget;
const selected = this._rowSortSelected === handle.index;
if (ev.key === "Escape" && selected) {
ev.preventDefault();
ev.stopPropagation();
this._rowSortSelected = undefined;
return;
}
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleDragClick(ev);
return;
}
if (
(selected || ev.altKey) &&
!ev.ctrlKey &&
!ev.metaKey &&
!ev.shiftKey &&
(ev.key === "ArrowUp" || ev.key === "ArrowDown")
) {
ev.preventDefault();
ev.stopPropagation();
const newIndex =
ev.key === "ArrowUp" ? handle.index - 1 : handle.index + 1;
if (newIndex < 0 || newIndex >= this.conditions.length) {
return;
}
this._move(handle.index, newIndex);
handle.focus();
}
}
private _addCondition(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value as string;
const conditions = [...this.conditions];
@@ -225,15 +417,34 @@ export class HaCardConditionsEditor extends LitElement {
fireEvent(this, "value-changed", { value: conditions });
}
private _conditionChanged(ev: CustomEvent) {
private _conditionChanged(
ev: ValueChangedEvent<VisibilityCondition | null> &
HASSDomTargetEvent<HaCardConditionEditor>
) {
ev.stopPropagation();
const conditions = [...this.conditions];
const newValue = ev.detail.value;
const index = (ev.target as any).index;
if (
newValue !== null &&
(typeof newValue !== "object" || Array.isArray(newValue))
) {
return;
}
const conditions = [...this.conditions];
const index = ev.target.index;
if (newValue === null) {
conditions.splice(index, 1);
if (this._rowSortSelected === index) {
this._rowSortSelected = undefined;
} else if (
this._rowSortSelected !== undefined &&
index < this._rowSortSelected
) {
this._rowSortSelected--;
}
} else {
this._conditionKeys.set(newValue, this._getKey(conditions[index]));
conditions[index] = newValue;
}
@@ -252,6 +463,24 @@ export class HaCardConditionsEditor extends LitElement {
margin-top: 12px;
scroll-margin-top: 48px;
}
.handle {
padding: var(--ha-space-1);
cursor: move;
cursor: grab;
border-radius: var(--ha-border-radius-pill);
}
.handle:focus {
outline: var(--wa-focus-ring);
background: var(--ha-color-fill-neutral-quiet-resting);
}
.handle.active {
outline: var(--wa-focus-ring);
background: var(--ha-color-fill-neutral-normal-active);
}
.handle ha-svg-icon {
display: block;
pointer-events: none;
}
ha-dropdown {
display: inline-block;
margin-top: var(--ha-space-3);
@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from "vitest";
import "../../../../../src/panels/lovelace/editor/conditions/ha-card-conditions-editor";
import type { HaCardConditionsEditor } from "../../../../../src/panels/lovelace/editor/conditions/ha-card-conditions-editor";
import type { Condition } from "../../../../../src/panels/lovelace/common/validate-condition";
const CONDITIONS: Condition[] = [
{ condition: "state", entity: "light.first", state: "on" },
{ condition: "state", entity: "light.second", state: "off" },
{ condition: "state", entity: "light.third", state: "on" },
];
const createEditor = () => {
const editor = document.createElement(
"ha-card-conditions-editor"
) as HaCardConditionsEditor;
editor.conditions = CONDITIONS;
return editor;
};
const waitForValueChanged = (editor: HaCardConditionsEditor) =>
new Promise<Condition[]>((resolve) => {
editor.addEventListener(
"value-changed",
(ev) => resolve(ev.detail.value as Condition[]),
{ once: true }
);
});
describe("ha-card-conditions-editor sorting", () => {
it("moves conditions without mutating the input array", async () => {
const editor = createEditor();
const input = editor.conditions;
const changed = waitForValueChanged(editor);
const stopPropagation = vi.fn();
(editor as any)._conditionMoved({
detail: { oldIndex: 0, newIndex: 2 },
stopPropagation,
});
const value = await changed;
expect(stopPropagation).toHaveBeenCalledOnce();
expect(value).toEqual([CONDITIONS[1], CONDITIONS[2], CONDITIONS[0]]);
expect(value).not.toBe(input);
expect(input).toEqual(CONDITIONS);
});
it("inserts a condition received from another list", async () => {
const editor = createEditor();
const added: Condition = {
condition: "user",
users: ["test-user"],
};
const changed = waitForValueChanged(editor);
await (editor as any)._conditionAdded({
detail: { index: 1, data: added },
stopPropagation: vi.fn(),
});
await expect(changed).resolves.toEqual([
CONDITIONS[0],
added,
CONDITIONS[1],
CONDITIONS[2],
]);
});
it("does not insert a cross-level condition twice after a parent rerender", async () => {
const editor = createEditor();
const added: Condition = {
condition: "user",
users: ["test-user"],
};
const changed = waitForValueChanged(editor);
const addition = (editor as any)._conditionAdded({
detail: { index: 1, data: added },
stopPropagation: vi.fn(),
});
editor.conditions = [CONDITIONS[0], added, CONDITIONS[1], CONDITIONS[2]];
await addition;
await expect(changed).resolves.toEqual([
CONDITIONS[0],
added,
CONDITIONS[1],
CONDITIONS[2],
]);
});
it("removes a condition sent to another list", async () => {
const editor = createEditor();
const changed = waitForValueChanged(editor);
await (editor as any)._conditionRemoved({
detail: { index: 1 },
stopPropagation: vi.fn(),
});
await expect(changed).resolves.toEqual([CONDITIONS[0], CONDITIONS[2]]);
});
it("keeps keyboard selection aligned across cross-list changes", async () => {
const editor = createEditor();
(editor as any)._rowSortSelected = 1;
await (editor as any)._conditionAdded({
detail: { index: 0, data: { condition: "screen" } },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBe(2);
await (editor as any)._conditionRemoved({
detail: { index: 2 },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBeUndefined();
});
it("moves a row using its non-pointer actions", async () => {
const editor = createEditor();
const changed = waitForValueChanged(editor);
(editor as any)._moveUp({
currentTarget: { first: false, index: 1 },
stopPropagation: vi.fn(),
});
await expect(changed).resolves.toEqual([
CONDITIONS[1],
CONDITIONS[0],
CONDITIONS[2],
]);
});
it("keeps keyboard sorting attached to the moved condition", () => {
const editor = createEditor();
(editor as any)._rowSortSelected = 1;
(editor as any)._moveUp({
currentTarget: { first: false, index: 1 },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBe(0);
});
it("preserves a row key when a condition is replaced", () => {
const editor = createEditor();
const original = CONDITIONS[1];
const replacement: Condition = {
condition: "state",
entity: "light.second",
state: "unavailable",
};
const key = (editor as any)._getKey(original);
(editor as any)._conditionChanged({
detail: { value: replacement },
target: { index: 1 },
stopPropagation: vi.fn(),
});
expect((editor as any)._getKey(replacement)).toBe(key);
});
it("keeps keyboard selection aligned after deletion", () => {
const editor = createEditor();
(editor as any)._rowSortSelected = 2;
(editor as any)._conditionChanged({
detail: { value: null },
target: { index: 0 },
stopPropagation: vi.fn(),
});
expect((editor as any)._rowSortSelected).toBe(1);
});
it("does not move a row beyond a list boundary", () => {
const editor = createEditor();
const changed = vi.fn();
editor.addEventListener("value-changed", changed);
(editor as any)._moveUp({
currentTarget: { first: true, index: 0 },
stopPropagation: vi.fn(),
});
(editor as any)._moveDown({
currentTarget: { last: true, index: CONDITIONS.length - 1 },
stopPropagation: vi.fn(),
});
expect(changed).not.toHaveBeenCalled();
});
});