Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 31d066c0d7 Highlight the pasted card
After a paste it was hard to see which card was new. The pasted card now
fades out a primary-color outline for 1.5 s, the same hint the history
filter pane shows when it is already open. The animation lives in a shared
highlightElement helper in common/dom that the filter pane now uses too.
It animates the full outline shorthand, so callers need no transparent
outline of their own, and holds a steady outline instead of fading when
reduced motion is preferred.

Paste remembers the pasted card object. The wrapper whose slotted card
renders that object claims it once and runs the animation. Keying on the
object rather than the path matters: the view pushes the new config into
the old section elements before rebuilding them, so an old wrapper at the
pasted index would otherwise claim the highlight and be torn down. A
failed save clears it.
2026-10-23 11:47:30 +02:00
7 changed files with 101 additions and 15 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
const HIGHLIGHT_DURATION = 1500;
export const highlightElement = (element: HTMLElement): void => {
const style = getComputedStyle(element);
const outline = `${style.getPropertyValue("--ha-border-width-md") || "2px"} solid`;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
element.getAnimations().forEach((animation) => animation.cancel());
element.animate(
[
{ outline: `${outline} ${style.getPropertyValue("--primary-color")}` },
{ outline: `${outline} transparent` },
],
{
duration: HIGHLIGHT_DURATION,
easing: reducedMotion ? "steps(1, end)" : "ease-out",
}
);
};
+2 -9
View File
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { highlightElement } from "../common/dom/highlight-element";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
@@ -13,8 +14,6 @@ import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
const HIGHLIGHT_DURATION = 1500;
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
@@ -116,12 +115,7 @@ export class HaFilterPane extends LitElement {
}
public highlight() {
const color = getComputedStyle(this).getPropertyValue("--primary-color");
this.getAnimations().forEach((animation) => animation.cancel());
this.animate([{ outlineColor: color }, { outlineColor: "transparent" }], {
duration: HIGHLIGHT_DURATION,
easing: "ease-out",
});
highlightElement(this);
}
private _close() {
@@ -144,7 +138,6 @@ export class HaFilterPane extends LitElement {
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
outline: var(--ha-border-width-md) solid transparent;
outline-offset: calc(-1 * var(--ha-border-width-md));
}
@@ -9,14 +9,21 @@ import {
mdiPencil,
mdiPlusCircleMultipleOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { storage } from "../../../common/decorators/storage";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { fireEvent } from "../../../common/dom/fire_event";
import { highlightElement } from "../../../common/dom/highlight-element";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -24,6 +31,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import type { HuiCard } from "../cards/hui-card";
import { takeCardHighlight } from "../editor/card-highlight";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@@ -64,6 +73,11 @@ export class HuiCardEditMode extends LitElement {
@state()
public _focused = false;
@query(".card-wrapper") private _cardWrapper!: HTMLDivElement;
@queryAssignedElements({ selector: "hui-card" })
private _huiCards?: HuiCard[];
private _touchStarted = false;
protected firstUpdated(): void {
@@ -107,6 +121,15 @@ export class HuiCardEditMode extends LitElement {
document.removeEventListener("click", this._documentClicked);
};
protected updated(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has("path") &&
takeCardHighlight(this._huiCards?.[0]?.config)
) {
highlightElement(this._cardWrapper);
}
}
protected render(): TemplateResult {
const showOverlay = (this._hover || this._focused) && !this.hiddenOverlay;
@@ -311,6 +334,10 @@ export class HuiCardEditMode extends LitElement {
position: relative;
height: 100%;
z-index: 0;
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.control {
@@ -20,6 +20,7 @@ import {
} from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { highlightElement } from "../../../common/dom/highlight-element";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
@@ -35,7 +36,9 @@ import {
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardSize } from "../common/compute-card-size";
import { takeCardHighlight } from "../editor/card-highlight";
import {
addCard,
moveCardToContainer,
@@ -64,6 +67,9 @@ export class HuiCardOptions extends LitElement {
@queryAssignedElements() private _assignedElements?: LovelaceCard[];
@queryAssignedElements({ selector: "hui-card" })
private _huiCards?: HuiCard[];
@property({ attribute: "hide-position", type: Boolean })
public hidePosition = false;
@@ -99,6 +105,9 @@ export class HuiCardOptions extends LitElement {
viewPath
);
this.classList.toggle("panel", viewConfig?.panel);
if (takeCardHighlight(this._huiCards?.[0]?.config)) {
highlightElement(this);
}
}
private get _cards() {
@@ -0,0 +1,22 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
type PastedItem = LovelaceCardConfig | LovelaceBadgeConfig;
let pending: PastedItem | undefined;
export const setCardHighlight = (item: PastedItem): void => {
pending = item;
};
export const clearCardHighlight = (): void => {
pending = undefined;
};
export const takeCardHighlight = (item: PastedItem | undefined): boolean => {
if (!item || pending !== item) {
return false;
}
pending = undefined;
return true;
};
@@ -7,6 +7,7 @@ import type { Lovelace } from "../types";
import { LOVELACE_EDIT_TOAST_ID } from "../types";
import { performDeleteBadge } from "./delete-badge";
import { performDeleteCard } from "./delete-card";
import { clearCardHighlight, setCardHighlight } from "./card-highlight";
import type { LovelacePath } from "./lovelace-path";
import { getParentPath, insertAtPath, setAtPath } from "./lovelace-path";
@@ -73,12 +74,18 @@ export async function performPaste<K extends ClipboardItemType>(
if (typeof index !== "number") {
throw new Error("Paste target is not an item");
}
const pastedPath = replace ? path : [...getParentPath(path), index + 1];
const item = deepClone(config);
const newConfig = replace
? setAtPath(lovelace.config, path, deepClone(config))
: insertAtPath(lovelace.config, pastedPath, deepClone(config));
? setAtPath(lovelace.config, path, item)
: insertAtPath(
lovelace.config,
[...getParentPath(path), index + 1],
item
);
setCardHighlight(item);
await lovelace.saveConfig(newConfig);
} catch (_err: unknown) {
clearCardHighlight();
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.paste_error`),
@@ -7,6 +7,7 @@ import {
performCut,
performPaste,
} from "../../../../src/panels/lovelace/editor/clipboard-actions";
import { takeCardHighlight } from "../../../../src/panels/lovelace/editor/card-highlight";
import type { LovelacePath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import { getAtPath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../../../../src/panels/lovelace/types";
@@ -193,6 +194,7 @@ describe("performPaste", () => {
{ path: ["views", 0, "cards", 0] }
);
expect(cardsAt(["views", 0, "cards"])).toEqual([CARD_A, CLIPBOARD, CARD_B]);
expect(takeCardHighlight(cardsAt(["views", 0, "cards"])[1])).toBe(true);
const { cardsAt: nestedCardsAt } = await pasteInto(
{ views: [{ sections: [{ cards: [CARD_A, CARD_B] }] }] },
@@ -221,6 +223,7 @@ describe("performPaste", () => {
{ path: ["views", 0, "cards", 0], replace: true }
);
expect(cardsAt(["views", 0, "cards"])).toEqual([CLIPBOARD]);
expect(takeCardHighlight(cardsAt(["views", 0, "cards"])[0])).toBe(true);
expect(showToast).toHaveBeenLastCalledWith(
expect.objectContaining({
message: "ui.panel.lovelace.editor.edit_card.replaced",
@@ -235,7 +238,7 @@ describe("performPaste", () => {
const lovelace = {
config,
rawConfig: config,
saveConfig: () => Promise.reject(new Error("save failed")),
saveConfig: vi.fn(() => Promise.reject(new Error("save failed"))),
showToast: vi.fn(),
} as Partial<Lovelace> as Lovelace;
@@ -250,5 +253,12 @@ describe("performPaste", () => {
message: "ui.panel.lovelace.editor.edit_card.paste_error",
})
);
const attempted = vi.mocked(lovelace.saveConfig).mock
.calls[0][0] as LovelaceConfig;
expect(
takeCardHighlight(
getAtPath<LovelaceCardConfig[]>(attempted, ["views", 0, "cards"])![1]
)
).toBe(false);
});
});