Compare commits

...

10 Commits

Author SHA1 Message Date
Aidan Timson af0529c118 Aggregate connected dirty state providers 2026-07-14 13:28:06 +01:00
Aidan Timson 4cb3680f27 Publish dirty state from editor updates 2026-07-14 13:16:37 +01:00
Aidan Timson 9127e8b13c Document dirty state provider ownership 2026-07-14 12:57:58 +01:00
Aidan Timson fd12ce921f Test notification toast lifecycle 2026-07-14 12:48:54 +01:00
Aidan Timson 574d097a7c Render primary toast actions as filled 2026-07-14 12:35:46 +01:00
Aidan Timson 310fef33db Cover update dependency behaviour 2026-07-14 12:33:32 +01:00
Aidan Timson 66d48e0d2c Add baseline tests for update dependencies 2026-07-14 12:25:48 +01:00
Aidan Timson 8b178ed421 Automatically activate frontend updates 2026-07-14 12:14:46 +01:00
Aidan Timson 195f49f2af Publish global dirty state changes 2026-07-14 12:14:32 +01:00
Aidan Timson 3b49dcb579 Support accessible toast actions 2026-07-14 12:14:20 +01:00
20 changed files with 1472 additions and 47 deletions
+22 -2
View File
@@ -24,6 +24,8 @@ export interface ToastClosedEventDetail {
export class HaToast extends LitElement {
@property({ attribute: "label-text" }) public labelText = "";
@property({ attribute: "announce-text" }) public announceText?: string;
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
@@ -190,8 +192,6 @@ export class HaToast extends LitElement {
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
@@ -200,6 +200,14 @@ export class HaToast extends LitElement {
<slot name="dismiss"></slot>
</div>
</div>
<span
class="assistive-message"
role="status"
aria-live=${this._active ? "polite" : "off"}
aria-atomic="true"
>
${this.announceText ?? this.labelText}
</span>
`;
}
@@ -254,6 +262,18 @@ export class HaToast extends LitElement {
min-width: 0;
}
.assistive-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.actions {
display: flex;
align-items: center;
+10
View File
@@ -36,3 +36,13 @@ export interface DirtyStateContext<
* boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
declare global {
interface Window {
isDirtyState?: boolean;
}
interface HASSDomEvents {
"dirty-state-changed": { isDirty: boolean };
}
}
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!changedProperties.has("_params") || !this._params) {
return;
}
+50 -26
View File
@@ -13,8 +13,13 @@ export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
announceMessage?:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
action?: ToastActionParams;
secondaryAction?: ToastActionParams;
duration?: number;
dismissable?: boolean;
bottomOffset?: number;
@@ -22,8 +27,10 @@ export interface ShowToastParams {
export interface ToastActionParams {
action: () => void;
primary?: boolean;
text:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
@customElement("notification-manager")
@@ -89,31 +96,22 @@ class NotificationManager extends LitElement {
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${
this._parameters?.action
? html`
<ha-button
appearance="plain"
size="s"
slot="action"
@click=${this._buttonClicked}
>
${
typeof this._parameters?.action.text !== "string"
? this.hass.localize(
this._parameters?.action.text.translationKey,
this._parameters?.action.text.args
)
: this._parameters?.action.text
}
</ha-button>
`
: nothing
}
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
@@ -130,11 +128,37 @@ class NotificationManager extends LitElement {
`;
}
private _renderAction(
action: ToastActionParams | undefined,
secondary: boolean
) {
if (!action) {
return nothing;
}
return html`
<ha-button
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@click=${secondary ? this._secondaryButtonClicked : this._buttonClicked}
>
${
typeof action.text !== "string"
? this.hass.localize(action.text.translationKey, action.text.args)
: action.text
}
</ha-button>
`;
}
private _buttonClicked() {
this._toast?.hide("action");
if (this._parameters?.action) {
this._parameters?.action.action();
}
this._parameters?.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
}
private _dismissClicked() {
+35 -1
View File
@@ -1,7 +1,8 @@
import { provide } from "@lit/context";
import deepClone from "deep-clone-simple";
import type { LitElement } from "lit";
import type { LitElement, PropertyValues } from "lit";
import { state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
@@ -17,6 +18,18 @@ export type CompareStrategy<State> =
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
const connectedDirtyStateProviders = new Map<object, boolean>();
const publishGlobalDirtyState = (): void => {
const isDirty = Array.from(connectedDirtyStateProviders.values()).some(
Boolean
);
if (isDirty !== window.isDirtyState) {
window.isDirtyState = isDirty;
fireEvent(window, "dirty-state-changed", { isDirty });
}
};
/**
* Mixin that provides dirty-state tracking via Lit context.
*
@@ -26,6 +39,9 @@ export type CompareStrategy<State> =
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* Connected providers contribute to the global dirty state. It remains dirty
* while any provider has unsaved changes.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
@@ -118,6 +134,24 @@ export const DirtyStateProviderMixin =
this._dirtyStateContext = this._buildContextValue();
}
public connectedCallback(): void {
super.connectedCallback();
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
public disconnectedCallback(): void {
connectedDirtyStateProviders.delete(this);
publishGlobalDirtyState();
super.disconnectedCallback();
}
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
const slice = this._dirtySlices.get(key);
if (!slice) {
@@ -203,6 +203,7 @@ class DialogBackupOnboarding
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_step") && this._step === "key") {
this._save();
}
@@ -146,6 +146,7 @@ export class HuiDialogEditBadge
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!changedProps.has("_badgeConfig")) {
return;
}
@@ -135,6 +135,7 @@ export class HuiDialogEditCard
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
return;
}
@@ -76,6 +76,7 @@ export class HuiDialogEditSection
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const sectionConfig = {
...this._config,
@@ -94,6 +94,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const viewConfig = {
...this._config,
@@ -43,6 +43,7 @@ export class HuiDialogEditViewFooter extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -44,6 +44,7 @@ export class HuiDialogEditViewHeader extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
+1
View File
@@ -104,6 +104,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
const oldLovelace = changedProps.get("lovelace") as Lovelace | undefined;
if (
!this._saving &&
+2 -2
View File
@@ -2504,8 +2504,8 @@
"triggered": "Triggered {name}",
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}",
"new_version_available": "A new version of the frontend is available.",
"reload": "Reload",
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
"update_now": "Update now",
"theme_save_failed": "Unable to save theme settings to your user profile.",
"theme_preferences_unavailable": "Unable to load user profile theme settings."
},
+94 -16
View File
@@ -1,5 +1,8 @@
import { showToast } from "./toast";
const UPDATE_DELAY = 60_000;
const UPDATE_TOAST_ID = "frontend-update-available";
export const supportsServiceWorker = () =>
"serviceWorker" in navigator &&
(location.protocol === "https:" || location.hostname === "localhost");
@@ -23,6 +26,92 @@ export const registerServiceWorker = async (
return;
}
let pendingWorker: ServiceWorker | undefined;
let updateDeadline = 0;
let updateInterval = 0;
const hideUpdateToast = () => {
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: "",
duration: 0,
});
};
const clearUpdateCountdown = () => {
clearInterval(updateInterval);
};
const activateUpdate = () => {
if (!pendingWorker) {
return;
}
clearUpdateCountdown();
hideUpdateToast();
pendingWorker.postMessage({ type: "skipWaiting" });
pendingWorker = undefined;
};
const showUpdateToast = () => {
const seconds = Math.ceil((updateDeadline - Date.now()) / 1000);
if (seconds <= 0) {
activateUpdate();
return;
}
const announceSeconds =
seconds > 40 ? 60 : seconds > 20 ? 40 : seconds > 5 ? 20 : 5;
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: announceSeconds },
},
action: {
action: activateUpdate,
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
action: () => {
pendingWorker = undefined;
clearUpdateCountdown();
},
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
};
const startUpdateCountdown = () => {
updateDeadline = Date.now() + UPDATE_DELAY;
showUpdateToast();
updateInterval = window.setInterval(showUpdateToast, 1000);
};
window.addEventListener("dirty-state-changed", () => {
if (!pendingWorker) {
return;
}
if (window.isDirtyState) {
clearUpdateCountdown();
hideUpdateToast();
return;
}
startUpdateCountdown();
});
const updateReady = (worker: ServiceWorker) => {
clearUpdateCountdown();
pendingWorker = worker;
if (!window.isDirtyState) {
startUpdateCountdown();
}
};
reg.addEventListener("updatefound", () => {
const installingWorker = reg.installing;
@@ -37,22 +126,11 @@ export const registerServiceWorker = async (
) {
return;
}
// Notify users a new frontend is available.
showToast(rootEl, {
message: {
translationKey: "ui.notification_toast.new_version_available",
},
action: {
// We tell the service worker to call skipWaiting, which activates
// the new service worker. Above we listen for `controllerchange`
// so we reload the page once a new service worker activates.
action: () => installingWorker.postMessage({ type: "skipWaiting" }),
text: { translationKey: "ui.notification_toast.reload" },
},
duration: -1,
dismissable: false,
});
updateReady(installingWorker);
});
});
if (reg.waiting && navigator.serviceWorker.controller) {
updateReady(reg.waiting);
}
};
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaToast } from "../../src/components/ha-toast";
import "../../src/components/ha-toast";
let toast: HaToast | undefined;
const mountToast = async (properties: Partial<HaToast> = {}) => {
toast = document.createElement("ha-toast");
Object.assign(toast, properties);
document.body.append(toast);
await toast.updateComplete;
return toast;
};
afterEach(() => {
toast?.remove();
toast = undefined;
vi.useRealTimers();
});
const showToast = async (element: HaToast) => {
vi.useFakeTimers();
const shown = element.show();
await element.updateComplete;
Object.defineProperty(
element.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await shown;
};
describe("ha-toast", () => {
it("renders its message and bottom offset", async () => {
const element = await mountToast({
labelText: "Configuration saved",
bottomOffset: 24,
});
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
"Configuration saved"
);
expect(
(
element.shadowRoot?.querySelector(".toast") as HTMLElement
).style.getPropertyValue("--ha-toast-bottom-offset")
).toBe("24px");
});
it("renders assigned action and dismiss content", async () => {
toast = document.createElement("ha-toast");
const action = document.createElement("button");
action.slot = "action";
const dismiss = document.createElement("button");
dismiss.slot = "dismiss";
toast.append(action, dismiss);
document.body.append(toast);
await toast.updateComplete;
toast.requestUpdate();
await toast.updateComplete;
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="action"]')
?.assignedElements()
).toEqual([action]);
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
?.assignedElements()
).toEqual([dismiss]);
expect(
toast.shadowRoot
?.querySelector(".actions")
?.classList.contains("has-action")
).toBe(true);
});
it("keeps changing visual text separate from live-region text", async () => {
const element = await mountToast({
labelText: "Updating in 59 seconds",
announceText: "Updating in 60 seconds",
});
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
expect(visibleMessage.closest('[role="status"]')).toBeNull();
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
expect(liveRegion.getAttribute("role")).toBe("status");
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
});
it("falls back to announcing the visible message", async () => {
const element = await mountToast({ labelText: "Configuration saved" });
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.textContent?.trim()
).toBe("Configuration saved");
});
it("activates its live region while shown", async () => {
const element = await mountToast();
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(liveRegion.getAttribute("aria-live")).toBe("off");
await showToast(element);
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await element.hide(reason);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ reason });
expect(
element
.shadowRoot!.querySelector(".toast")!
.classList.contains("active")
).toBe(false);
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.getAttribute("aria-live")
).toBe("off");
}
);
it("closes with a timeout reason", async () => {
const element = await mountToast({ timeoutMs: 1000 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(1000);
expect(listener.mock.calls[0][0].detail).toEqual({ reason: "timeout" });
});
it("does not close automatically when the timeout is disabled", async () => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(10_000);
expect(listener).not.toHaveBeenCalled();
});
it("does not emit a close event when already hidden", async () => {
const element = await mountToast();
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await element.hide("dismiss");
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
const localize = (key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key;
@customElement("test-notification-lifecycle-host")
class TestNotificationLifecycleHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-lifecycle-host": TestNotificationLifecycleHost;
}
}
let host: TestNotificationLifecycleHost | undefined;
afterEach(() => {
host?.remove();
host = undefined;
vi.useRealTimers();
});
describe("notification toast lifecycle", () => {
it("updates a visible toast and closes it after its primary action", async () => {
vi.useFakeTimers();
host = document.createElement("test-notification-lifecycle-host");
document.body.append(host);
await host.updateComplete;
const manager = host.shadowRoot!.querySelector("notification-manager")!;
const action = vi.fn();
const firstShow = manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
await Promise.resolve();
await manager.updateComplete;
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
Object.defineProperty(
toast.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await firstShow;
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
"visible"
);
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":60'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
await manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":59'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
const closed = new Promise<void>((resolve) => {
toast.addEventListener("toast-closed", () => resolve(), { once: true });
});
manager.shadowRoot!.querySelector<HTMLElement>("ha-button")!.click();
await closed;
await manager.updateComplete;
expect(action).toHaveBeenCalledOnce();
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
+245
View File
@@ -0,0 +1,245 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key
);
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-toast", () => {
customElements.define(
"ha-toast",
class extends HTMLElement {
public show = vi.fn();
public hide = vi.fn();
}
);
return {};
});
@customElement("test-notification-manager-host")
class TestNotificationManagerHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-manager-host": TestNotificationManagerHost;
}
}
let host: TestNotificationManagerHost | undefined;
const mountManager = async () => {
host = document.createElement(
"test-notification-manager-host"
) as TestNotificationManagerHost;
document.body.append(host);
await host.updateComplete;
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("notification-manager", () => {
it("shows a message with the default duration", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(toast.bottomOffset).toBe(0);
expect(toast.show).toHaveBeenCalledOnce();
});
it("renders a dismiss button and closes with the dismiss reason", async () => {
const manager = await mountManager();
await manager.showDialog({
message: "Connection lost",
dismissable: true,
duration: -1,
bottomOffset: 16,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.timeoutMs).toBe(-1);
expect(toast.bottomOffset).toBe(16);
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the current notification when duration is zero", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
it.each([
{ duration: 1, expected: 4000 },
{ duration: 4000, expected: 4000 },
{ duration: 4001, expected: 4001 },
{ duration: -1, expected: -1 },
])(
"normalizes a $duration ms duration to $expected ms",
async ({ duration, expected }) => {
const manager = await mountManager();
await manager.showDialog({ message: "Message", duration });
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
expected
);
}
);
it("localizes visible and assistive messages with numeric arguments", async () => {
const manager = await mountManager();
await manager.showDialog({
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toContain('"seconds":59');
expect(toast.announceText).toContain('"seconds":60');
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 59 }
);
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 60 }
);
});
it("renders and invokes primary and secondary actions", async () => {
const manager = await mountManager();
const primary = vi.fn();
const secondary = vi.fn();
await manager.showDialog({
message: "Update available",
action: { action: primary, primary: true, text: "Update now" },
secondaryAction: { action: secondary, text: "Cancel" },
duration: -1,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent?.trim()).toBe("Cancel");
expect(buttons[0].getAttribute("appearance")).toBe("plain");
expect(buttons[1].textContent?.trim()).toBe("Update now");
expect(buttons[1].getAttribute("appearance")).toBe("filled");
buttons[0].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(secondary).toHaveBeenCalledOnce();
expect(primary).not.toHaveBeenCalled();
buttons[1].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(primary).toHaveBeenCalledOnce();
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "status", message: "Second" });
expect(toast.hide).not.toHaveBeenCalled();
expect(toast.labelText).toBe("Second");
});
it("hides a toast before replacing it with a different ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "second", message: "Second" });
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
});
it("ignores a stale replacement after a newer notification starts", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
expect(toast.labelText).toBe("Third");
});
it("clears rendered state after the toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Message" });
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
new CustomEvent("toast-closed", {
detail: { reason: "programmatic" },
})
);
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
@@ -0,0 +1,368 @@
import { consume } from "@lit/context";
import { LitElement } from "lit";
import { state } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../src/data/context/dirty-state";
import type { CompareStrategy } from "../../src/mixins/dirty-state-provider-mixin";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
interface TestState {
name: string;
enabled: boolean;
options?: { mode: string };
}
class TestDirtyStateConsumer extends LitElement {
@consume({ context: dirtyStateContext, subscribe: true })
@state()
public dirtyState?: DirtyStateContext;
}
customElements.define("test-dirty-state-consumer", TestDirtyStateConsumer);
class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
LitElement
) {
public initialize(
strategy: CompareStrategy<TestState>,
value?: TestState,
normalize?: (value: TestState) => TestState
) {
this._initDirtyTracking(strategy, value, normalize);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
public discardChanges() {
this._discardDirtyStateChanges();
}
}
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
declare global {
interface HTMLElementTagNameMap {
"test-dirty-state-consumer": TestDirtyStateConsumer;
"test-dirty-state-provider": TestDirtyStateProvider;
}
}
let provider: TestDirtyStateProvider | undefined;
const mountProvider = async () => {
provider = document.createElement(
"test-dirty-state-provider"
) as TestDirtyStateProvider;
document.body.append(provider);
await provider.updateComplete;
return provider;
};
afterEach(() => {
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
element.remove();
});
provider = undefined;
window.isDirtyState = false;
});
describe("DirtyStateProviderMixin", () => {
it("tracks changes against an initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("marks the current state as clean", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
element.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("discards changes back to the initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: true });
await element.updateComplete;
element.discardChanges();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("uses deep comparison for nested values", async () => {
const element = await mountProvider();
element.initialize(
{ type: "deep" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "manual" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("uses reference comparison for nested values with a shallow strategy", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("supports a custom comparison strategy", async () => {
const element = await mountProvider();
const compare = vi.fn(
(initial: TestState, current: TestState) =>
initial.name.toLowerCase() === current.name.toLowerCase()
);
element.initialize(
{ type: "custom", compare },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "KITCHEN", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(compare).toHaveBeenCalled();
});
it("keeps the deep baseline isolated from initial state mutations", async () => {
const element = await mountProvider();
const initial = {
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
};
element.initialize({ type: "deep" }, initial);
initial.options.mode = "manual";
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("uses the first deferred value as its baseline", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("tracks raw and effective dirty state separately", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false },
(value) => ({ ...value, enabled: false })
);
element.setValue({ name: "Kitchen", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("publishes dirty and clean transitions with event details", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
listener.mockClear();
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(listener).toHaveBeenCalledTimes(2);
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: true });
expect(listener.mock.calls[1][0].detail).toEqual({ isDirty: false });
expect(window.isDirtyState).toBe(false);
window.removeEventListener("dirty-state-changed", listener);
});
it("clears published dirty state when disconnected", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
listener.mockClear();
element.remove();
expect(window.isDirtyState).toBe(false);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: false });
window.removeEventListener("dirty-state-changed", listener);
});
it("hands global dirty state to the next provider after disconnecting", async () => {
const firstProvider = await mountProvider();
firstProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
firstProvider.setValue({ name: "Bedroom", enabled: false });
await firstProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
firstProvider.remove();
expect(window.isDirtyState).toBe(false);
const nextProvider = await mountProvider();
nextProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
nextProvider.setValue({ name: "Hallway", enabled: true });
await nextProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
});
it("stays globally dirty while any connected provider is dirty", async () => {
const dirtyProvider = await mountProvider();
dirtyProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
dirtyProvider.setValue({ name: "Bedroom", enabled: false });
await dirtyProvider.updateComplete;
const cleanProvider = document.createElement("test-dirty-state-provider");
document.body.append(cleanProvider);
cleanProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
await cleanProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
cleanProvider.remove();
expect(window.isDirtyState).toBe(true);
dirtyProvider.setValue({ name: "Kitchen", enabled: false });
await dirtyProvider.updateComplete;
expect(window.isDirtyState).toBe(false);
});
it("restores a provider's dirty state after reconnecting", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.remove();
expect(window.isDirtyState).toBe(false);
document.body.append(element);
expect(window.isDirtyState).toBe(true);
});
it("tracks independent context slices and marks all of them clean", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
const consumer = document.createElement("test-dirty-state-consumer");
element.append(consumer);
await consumer.updateComplete;
const setState = (value: TestState, key: "first" | "second") => {
Reflect.apply(consumer.dirtyState!.setState, undefined, [value, key]);
};
setState({ name: "Kitchen", enabled: false }, "first");
setState({ name: "Bedroom", enabled: false }, "second");
await element.updateComplete;
setState({ name: "Kitchen light", enabled: false }, "first");
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
consumer.dirtyState!.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
});
+341
View File
@@ -0,0 +1,341 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ShowToastParams } from "../../src/managers/notification-manager";
import {
registerServiceWorker,
supportsServiceWorker,
} from "../../src/util/register-service-worker";
class FakeWorker extends EventTarget {
public state: ServiceWorkerState = "installing";
public postMessage = vi.fn();
}
class FakeRegistration extends EventTarget {
public installing: ServiceWorker | null = null;
public waiting: ServiceWorker | null = null;
}
class FakeServiceWorkerContainer extends EventTarget {
public controller: ServiceWorker | null = null;
public register = vi.fn();
}
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"serviceWorker"
);
const restoreServiceWorker = () => {
if (serviceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", serviceWorkerDescriptor);
} else {
Reflect.deleteProperty(navigator, "serviceWorker");
}
};
describe("supportsServiceWorker", () => {
afterEach(restoreServiceWorker);
it("returns true when the service worker API is available", () => {
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: new FakeServiceWorkerContainer(),
});
expect(supportsServiceWorker()).toBe(true);
});
it("returns false when the service worker API is unavailable", () => {
Reflect.deleteProperty(navigator, "serviceWorker");
expect(supportsServiceWorker()).toBe(false);
});
});
describe("registerServiceWorker", () => {
let root: HTMLElement;
let worker: FakeWorker;
let registration: FakeRegistration;
let serviceWorker: FakeServiceWorkerContainer;
let notifications: ShowToastParams[];
const latestNotification = () => notifications[notifications.length - 1];
const setDirtyState = (isDirty: boolean) => {
window.isDirtyState = isDirty;
window.dispatchEvent(
new CustomEvent("dirty-state-changed", { detail: { isDirty } })
);
};
const installUpdate = () => {
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "installed";
worker.dispatchEvent(new Event("statechange"));
};
beforeEach(() => {
vi.useFakeTimers();
globalThis.__BUILD__ = "modern";
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
window.isDirtyState = false;
notifications = [];
root = document.createElement("div");
root.addEventListener("hass-notification", (event) => {
notifications.push((event as CustomEvent<ShowToastParams>).detail);
});
worker = new FakeWorker();
registration = new FakeRegistration();
serviceWorker = new FakeServiceWorkerContainer();
serviceWorker.controller = worker as unknown as ServiceWorker;
serviceWorker.register.mockResolvedValue(registration);
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
});
afterEach(() => {
latestNotification()?.secondaryAction?.action();
vi.clearAllTimers();
vi.useRealTimers();
window.isDirtyState = false;
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
restoreServiceWorker();
});
it("registers the worker for the current build", async () => {
await registerServiceWorker(root, false);
expect(serviceWorker.register).toHaveBeenCalledOnce();
expect(serviceWorker.register).toHaveBeenCalledWith("/sw-modern.js");
});
it("does not register when service workers are unsupported", async () => {
Reflect.deleteProperty(navigator, "serviceWorker");
await registerServiceWorker(root);
expect(serviceWorker.register).not.toHaveBeenCalled();
});
it("listens for controller changes before registering", async () => {
const addEventListener = vi.spyOn(serviceWorker, "addEventListener");
await registerServiceWorker(root, false);
expect(addEventListener).toHaveBeenCalledWith(
"controllerchange",
expect.any(Function)
);
expect(addEventListener.mock.invocationCallOrder[0]).toBeLessThan(
serviceWorker.register.mock.invocationCallOrder[0]
);
});
it.each([
{ notifyUpdate: false, dev: false, demo: false },
{ notifyUpdate: true, dev: true, demo: false },
{ notifyUpdate: true, dev: false, demo: true },
])(
"does not monitor updates with notifyUpdate=$notifyUpdate, dev=$dev, demo=$demo",
async ({ notifyUpdate, dev, demo }) => {
globalThis.__DEV__ = dev;
globalThis.__DEMO__ = demo;
const addEventListener = vi.spyOn(registration, "addEventListener");
await registerServiceWorker(root, notifyUpdate);
expect(addEventListener).not.toHaveBeenCalledWith(
"updatefound",
expect.any(Function)
);
}
);
it("ignores update discovery without an installing worker", async () => {
await registerServiceWorker(root);
registration.dispatchEvent(new Event("updatefound"));
expect(notifications).toHaveLength(0);
});
it("waits for an installing worker to reach the installed state", async () => {
await registerServiceWorker(root);
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "activating";
worker.dispatchEvent(new Event("statechange"));
expect(notifications).toHaveLength(0);
});
it("ignores an initial installation without an existing controller", async () => {
serviceWorker.controller = null;
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
});
it("starts at 60 seconds when an update is installed", async () => {
await registerServiceWorker(root);
installUpdate();
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
args: { seconds: 60 },
},
action: {
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
});
it("uses an already waiting worker", async () => {
registration.waiting = worker as unknown as ServiceWorker;
await registerServiceWorker(root);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("ignores a waiting worker without an existing controller", async () => {
registration.waiting = worker as unknown as ServiceWorker;
serviceWorker.controller = null;
await registerServiceWorker(root);
expect(notifications).toHaveLength(0);
});
it.each([
{ elapsed: 1_000, visual: 59, announced: 60 },
{ elapsed: 20_000, visual: 40, announced: 40 },
{ elapsed: 40_000, visual: 20, announced: 20 },
{ elapsed: 55_000, visual: 5, announced: 5 },
{ elapsed: 59_000, visual: 1, announced: 5 },
])(
"shows $visual seconds and announces $announced after $elapsed ms",
async ({ elapsed, visual, announced }) => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(elapsed);
expect(latestNotification().message).toMatchObject({
args: { seconds: visual },
});
expect(latestNotification().announceMessage).toMatchObject({
args: { seconds: announced },
});
}
);
it("activates and hides the update when the countdown finishes", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: "",
duration: 0,
});
});
it("activates and hides the update immediately on request", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().action!.action();
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
});
it("cancels automatic activation", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().secondaryAction!.action();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
});
it("defers the message and activation while the page is dirty", async () => {
setDirtyState(true);
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("hides and resets the countdown when the page becomes dirty", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(10_000);
setDirtyState(true);
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("uses the latest installed worker", async () => {
await registerServiceWorker(root);
installUpdate();
const replacement = new FakeWorker();
replacement.state = "installed";
registration.installing = replacement as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
replacement.dispatchEvent(new Event("statechange"));
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
expect(replacement.postMessage).toHaveBeenCalledWith({
type: "skipWaiting",
});
});
});