Compare commits

..

4 Commits

Author SHA1 Message Date
Petar Petrov 9f5b8099a6 Cover the pointer path, display and arrow keys at the bounds 2026-08-04 14:33:09 +03:00
Petar Petrov a9cc652214 Clamp numeric controls after snapping to the step 2026-08-04 14:20:42 +03:00
renovate[bot] c0d963747a Update dependency @types/luxon to v3.7.3 (#53484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 13:14:02 +03:00
Paul Bottein 1bcd43fb75 Add form divider between actions in interaction editor (#53483) 2026-08-04 11:55:07 +02:00
22 changed files with 410 additions and 465 deletions
+1 -1
View File
@@ -164,7 +164,7 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.2",
"@types/luxon": "3.7.3",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
+5 -6
View File
@@ -53,8 +53,10 @@ export class HaControlNumberButton extends LitElement {
});
private _boundedValue(value: number) {
const clamped = conditionalClamp(value, this.min, this.max);
return Math.round(clamped / this._step) * this._step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
const stepped = Math.round(value / this._step) * this._step;
return conditionalClamp(stepped, this.min, this.max);
}
private get _step() {
@@ -67,10 +69,7 @@ export class HaControlNumberButton extends LitElement {
private get _tenPercentStep() {
if (this.max == null || this.min == null) return this._step;
const range = this.max - this.min / 10;
if (range <= this._step) return this._step;
return Math.max(range / 10);
return Math.max((this.max - this.min) / 10, this._step);
}
private _handlePlusButton() {
+5 -7
View File
@@ -115,7 +115,9 @@ export class HaControlSlider extends LitElement {
}
steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
return this.boundedValue(Math.round(value / this.step) * this.step);
}
private _displayedValue(value: number) {
@@ -254,13 +256,9 @@ export class HaControlSlider extends LitElement {
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
} else if (e.code === "PageDown") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
} else {
const isRtl = mainWindow.document.dir === "rtl";
let multiplier = 1;
+30
View File
@@ -0,0 +1,30 @@
import { css, html, LitElement } from "lit";
import type { TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaFormDividerSchema, HaFormElement } from "./types";
@customElement("ha-form-divider")
export class HaFormDivider extends LitElement implements HaFormElement {
@property({ attribute: false }) public schema!: HaFormDividerSchema;
protected render(): TemplateResult {
return html`<hr />`;
}
static styles = css`
:host {
display: block;
}
hr {
border: none;
border-top: 1px solid var(--ha-color-border-neutral-quiet);
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-divider": HaFormDivider;
}
}
@@ -12,9 +12,11 @@ import type { HaDropdownSelectEvent } from "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-svg-icon";
import "./ha-form";
import "./ha-form-divider";
import type { HaForm } from "./ha-form";
import type {
HaFormDataContainer,
HaFormDividerSchema,
HaFormElement,
HaFormOptionalActionsSchema,
HaFormSchema,
@@ -22,6 +24,11 @@ import type {
const NO_ACTIONS = [];
const DIVIDER = {
name: "",
type: "divider",
} as const satisfies HaFormDividerSchema;
@customElement("ha-form-optional_actions")
export class HaFormOptionalActions extends LitElement implements HaFormElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@@ -87,7 +94,9 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
schema: readonly HaFormSchema[],
displayActions: string[]
): HaFormSchema[] =>
schema.filter((item) => displayActions.includes(item.name))
schema
.filter((item) => displayActions.includes(item.name))
.flatMap((item) => [DIVIDER, item])
);
public render(): TemplateResult {
@@ -128,6 +137,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
${
hiddenActions.length > 0
? html`
<ha-form-divider></ha-form-divider>
<ha-dropdown
@wa-select=${this._handleAddAction}
@closed=${stopPropagation}
+1
View File
@@ -20,6 +20,7 @@ type HaFormDataContainerChangedEvent = ValueChangedEvent<HaFormDataContainer>;
const LOAD_ELEMENTS = {
boolean: () => import("./ha-form-boolean"),
constant: () => import("./ha-form-constant"),
divider: () => import("./ha-form-divider"),
float: () => import("./ha-form-float"),
grid: () => import("./ha-form-grid"),
expandable: () => import("./ha-form-expandable"),
+5
View File
@@ -4,6 +4,7 @@ import type { HaDurationData } from "../ha-duration-input";
export type HaFormSchema =
| HaFormConstantSchema
| HaFormDividerSchema
| HaFormStringSchema
| HaFormIntegerSchema
| HaFormFloatSchema
@@ -97,6 +98,10 @@ export interface HaFormConstantSchema extends HaFormBaseSchema {
value?: string;
}
export interface HaFormDividerSchema extends HaFormBaseSchema {
type: "divider";
}
export interface HaFormIntegerSchema extends HaFormBaseSchema {
type: "integer";
default?: HaFormIntegerData;
+3 -27
View File
@@ -31,8 +31,6 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -150,12 +148,7 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
return;
}
@@ -163,12 +156,7 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
return;
}
@@ -199,15 +187,12 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -268,15 +253,6 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
+82 -216
View File
@@ -1,20 +1,7 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -23,7 +10,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for updating or closing a specific toast without flickering.
// 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
@@ -47,150 +34,105 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _notifications: Notification[] = [];
@state() private _parameters?: ShowToastParams;
@query(".stack") private _stack?: HTMLDivElement;
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
private _showDialogId = 0;
public async showDialog(parameters: ShowToastParams) {
if (parameters.duration === 0) {
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (parameters.duration === 0) {
this._parameters = undefined;
return;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
this._parameters = parameters;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
await this.updateComplete;
this._showStack();
this._getToast(key)?.show();
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
}
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
if (ev.detail.reason === "dismiss") {
this._getNotification(key)?.parameters.dismiss?.();
this._parameters?.dismiss?.();
}
this._removeNotification(key);
this._parameters = undefined;
}
protected render() {
if (!this._notifications.length) {
if (!this._parameters) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: 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}
>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -199,7 +141,6 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -214,94 +155,19 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
}
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
}
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
private _dismissClicked() {
this._toast?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || this._stack?.matches(":popover-open")) {
return;
}
this._stack?.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
@@ -35,8 +35,6 @@ import type HaAutomationSidebar from "./ha-automation-sidebar";
export const SIDEBAR_DEFAULT_WIDTH = 500;
export const PASTED_CONFIG_TOAST_ID = "pasted-config";
export const ManualEditorMixin = <TConfig>(
superClass: Constructor<LitElement>
) => {
@@ -239,7 +237,6 @@ export const ManualEditorMixin = <TConfig>(
this.pastedConfig = undefined;
showToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: "",
duration: 0,
});
@@ -37,10 +37,7 @@ import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "./ha-manual-editor-mixin";
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
@@ -434,7 +431,6 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_toast_message"
),
@@ -33,10 +33,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showEditorToast } from "../automation/editor-toast";
import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "../automation/ha-manual-editor-mixin";
import { ManualEditorMixin } from "../automation/ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "../automation/styles";
import "./ha-script-fields";
@@ -342,7 +339,6 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.script.editor.paste_toast_message"
),
@@ -135,6 +135,10 @@ export class HuiShortcutBadgeEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -168,6 +168,10 @@ export class HuiShortcutCardEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -199,6 +199,10 @@ export class HuiTileCardEditor
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
+1 -9
View File
@@ -13,9 +13,6 @@ import { showToast } from "../util/toast";
import type { HassBaseEl } from "./hass-base-mixin";
import { navigate } from "../common/navigate";
const CONNECTION_LOST_TOAST_ID = "connection-lost";
const SERVER_STARTUP_TOAST_ID = "server-startup";
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
private _subscribedBootstrapIntegrations?: Promise<UnsubscribeFunc>;
@@ -37,7 +34,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (oldHass?.config?.state !== this.hass!.config.state) {
if (this.hass!.config.state === STATE_NOT_RUNNING) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.starting") ||
"Home Assistant is starting. Not everything will be available until it is finished.",
@@ -61,7 +57,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
) {
this._unsubscribeBootstrapIntegrations();
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.started"),
duration: 5000,
});
@@ -100,7 +95,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
return;
}
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: "",
duration: 0,
});
@@ -112,7 +106,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._disconnectedTimeout = window.setTimeout(() => {
this._disconnectedTimeout = undefined;
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.connection_lost"),
duration: -1,
dismissable: false,
@@ -127,7 +120,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (Object.keys(message).length === 0) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.wrapping_up_startup") ||
`Wrapping up startup. Not everything will be available until it is finished.`,
@@ -150,7 +142,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)[0][0];
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
id: "integration_starting",
message:
this.hass!.localize("ui.notification_toast.integration_starting", {
integration: domainToName(this.hass!.localize, integration),
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../src/components/ha-control-number-buttons";
import type { HaControlNumberButton } from "../../src/components/ha-control-number-buttons";
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
describe("ha-control-number-buttons step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
let buttons: HaControlNumberButton[] = [];
const mountButtons = async (
props: Partial<HaControlNumberButton>
): Promise<HaControlNumberButton> => {
const el = document.createElement(
"ha-control-number-buttons"
) as HaControlNumberButton;
Object.assign(el, props);
document.body.appendChild(el);
buttons.push(el);
await el.updateComplete;
return el;
};
const stepButton = (el: HaControlNumberButton, label: string) =>
el.shadowRoot!.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
const changedValues = (el: HaControlNumberButton) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
const pressKey = async (el: HaControlNumberButton, code: string) => {
el.shadowRoot!.querySelector('[role="spinbutton"]')!.dispatchEvent(
new KeyboardEvent("keydown", {
code,
bubbles: true,
composed: true,
cancelable: true,
})
);
await el.updateComplete;
};
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
buttons.forEach((el) => el.remove());
buttons = [];
vi.unstubAllGlobals();
});
it("stops at the maximum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([99]);
expect(stepButton(el, "increment").disabled).toBe(true);
});
it("stops at the minimum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 11 });
const values = changedValues(el);
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([1]);
expect(stepButton(el, "decrement").disabled).toBe(true);
});
it("still snaps to the step grid away from the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 44 });
const values = changedValues(el);
stepButton(el, "increment").click();
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([50, 40]);
});
it("steps without bounds when min and max are not set", async () => {
const el = await mountButtons({ step: 10, value: 50 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([60]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 11;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
it("pages by ten percent of the range, but at least one step", async () => {
const wide = await mountButtons({ min: 10, max: 20, step: 1, value: 15 });
const wideValues = changedValues(wide);
await pressKey(wide, "PageUp");
expect(wideValues).toEqual([16]);
// A range narrower than ten steps must still page by a full step.
const narrow = await mountButtons({ min: 0, max: 5, step: 2, value: 0 });
const narrowValues = changedValues(narrow);
await pressKey(narrow, "PageUp");
expect(narrowValues).toEqual([2]);
});
});
+97 -17
View File
@@ -8,6 +8,23 @@ const createSlider = (props: Partial<HaControlSlider>): HaControlSlider => {
return el;
};
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
describe("ha-control-slider value mapping", () => {
afterEach(() => {
document.dir = "ltr";
@@ -54,18 +71,6 @@ describe("ha-control-slider display rounding", () => {
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
const FAN_STEP = 100 / 91;
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
const ariaValueNow = (el: HaControlSlider) =>
el
.shadowRoot!.querySelector('[role="slider"]')!
@@ -74,11 +79,6 @@ describe("ha-control-slider display rounding", () => {
const tooltipText = (el: HaControlSlider) =>
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
it("shows the fractional stepped value by default", async () => {
const el = await mountSlider({ step: FAN_STEP, value: 29 });
expect(tooltipText(el)).toBe("28.57");
@@ -113,3 +113,83 @@ describe("ha-control-slider display rounding", () => {
expect(ariaValueNow(el)).toBe("21.5");
});
});
describe("ha-control-slider step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
const pressKey = async (el: HaControlSlider, code: string) => {
const slider = el.shadowRoot!.querySelector('[role="slider"]')!;
const init = { code, bubbles: true, composed: true, cancelable: true };
slider.dispatchEvent(new KeyboardEvent("keydown", init));
slider.dispatchEvent(new KeyboardEvent("keyup", init));
await el.updateComplete;
};
const changedValues = (el: HaControlSlider) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
it("snaps within the bounds instead of rounding past them", () => {
const el = createSlider(RANGE);
expect(el.steppedValue(99)).toBe(99);
expect(el.steppedValue(1)).toBe(1);
// Values away from the bounds still snap to the step grid.
expect(el.steppedValue(44)).toBe(40);
expect(el.steppedValue(46)).toBe(50);
});
it("stays inside the bounds along the whole pointer path", () => {
// The pointer handlers compose these two, so they have to hold together.
const el = createSlider(RANGE);
expect(el.steppedValue(el.percentageToValue(1))).toBe(99);
expect(el.steppedValue(el.percentageToValue(0))).toBe(1);
});
it("does not overshoot the maximum with a fractional step", () => {
// A 91-speed fan: 91 * (100 / 91) used to land on 100.00000000000001.
const el = createSlider({ min: 0, max: 100, step: 100 / 91 });
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
});
it("shows and announces the bound, not the overshoot", async () => {
const el = await mountSlider({
...RANGE,
value: 99,
tooltipMode: "always",
});
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
"99"
);
expect(
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow")
).toBe("99");
});
it("keeps paging inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "PageUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "PageDown");
expect(values).toEqual([99, 1]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
});
-13
View File
@@ -118,19 +118,6 @@ describe("ha-toast", () => {
).toBe(true);
});
it("shows as part of a stack without becoming a popover", async () => {
const element = await mountToast({ stacked: true });
await showToast(element);
expect(
element.shadowRoot!.querySelector(".toast")!.hasAttribute("popover")
).toBe(false);
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
+27 -144
View File
@@ -60,6 +60,14 @@ const mountManager = async () => {
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;
@@ -73,12 +81,9 @@ describe("notification-manager", () => {
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("0px");
expect(toast.bottomOffset).toBe(0);
expect(toast.show).toHaveBeenCalledOnce();
});
@@ -93,34 +98,21 @@ describe("notification-manager", () => {
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.timeoutMs).toBe(-1);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("16px");
expect(toast.bottomOffset).toBe(16);
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the latest anonymous notification when duration is zero without an ID", async () => {
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({
id: "identified",
message: "Identified message",
duration: -1,
});
await manager.showDialog({ message: "Second message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["First message", "Identified message"]);
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
it.each([
@@ -198,32 +190,6 @@ describe("notification-manager", () => {
expect(primary).toHaveBeenCalledOnce();
});
it("invokes the action belonging to the selected stacked toast", async () => {
const manager = await mountManager();
const firstAction = vi.fn();
const secondAction = vi.fn();
await manager.showDialog({
id: "first",
message: "First",
action: { action: firstAction, text: "First action" },
duration: -1,
});
await manager.showDialog({
id: "second",
message: "Second",
action: { action: secondAction, text: "Second action" },
duration: -1,
});
manager.shadowRoot!.querySelectorAll("ha-button")[1].click();
expect(secondAction).toHaveBeenCalledOnce();
expect(firstAction).not.toHaveBeenCalled();
expect(
manager.shadowRoot!.querySelectorAll("ha-toast")[1].hide
).toHaveBeenCalledWith("action");
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
@@ -236,114 +202,31 @@ describe("notification-manager", () => {
expect(toast.labelText).toBe("Second");
});
it("stacks toasts with different IDs", async () => {
it("hides a toast before replacing it with a different ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(2);
expect([...toasts].map((toast) => toast.labelText)).toEqual([
"First",
"Second",
]);
});
it("uses the largest bottom offset for the notification stack", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "first",
message: "First",
bottomOffset: 16,
});
await manager.showDialog({
id: "second",
message: "Second",
bottomOffset: 48,
});
expect(
manager
.shadowRoot!.querySelector<HTMLElement>(".stack")!
.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("48px");
});
it("closes only the toast matching a duration-zero ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "first", message: "", duration: 0 });
await manager.updateComplete;
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(1);
expect(toasts[0].labelText).toBe("Second");
});
it("keeps a same-ID update that arrives while the previous toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
let finishHide: () => void;
vi.mocked(toast.hide).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishHide = resolve;
})
);
vi.mocked(toast.hide).mockClear();
const closing = manager.showDialog({
id: "status",
message: "",
duration: 0,
});
await Promise.resolve();
await manager.showDialog({ id: "status", message: "Second" });
finishHide!();
await closing;
await manager.updateComplete;
await manager.showDialog({ id: "second", message: "Second" });
const remainingToast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(remainingToast.labelText).toBe("Second");
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
});
it("keeps a frontend update visible throughout server startup", async () => {
it("ignores a stale replacement after a newer notification starts", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "frontend-update-available",
message: "Frontend update available",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Home Assistant is starting",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Starting recorder",
duration: -1,
});
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Starting recorder"]);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
await manager.showDialog({
id: "server-startup",
message: "Home Assistant started",
duration: 5000,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Home Assistant started"]);
expect(toast.labelText).toBe("Third");
});
it("clears rendered state after the toast closes", async () => {
@@ -4,7 +4,6 @@ import "../../../../src/panels/config/script/manual-script-editor";
import type { HaManualScriptEditor } from "../../../../src/panels/config/script/manual-script-editor";
import { createMockHass } from "../../../fixtures/hass";
import { showPasteReplaceDialog } from "../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace";
import { PASTED_CONFIG_TOAST_ID } from "../../../../src/panels/config/automation/ha-manual-editor-mixin";
const pasteCases = [
{
@@ -164,8 +163,6 @@ describe("manual automation paste", () => {
once: true,
});
});
const notification = vi.fn();
el.addEventListener("hass-notification", notification);
// Call the protected method through `any` to avoid full DOM lifecycle.
await (el as any).handlePaste(makePasteEvent(paste));
@@ -174,9 +171,6 @@ describe("manual automation paste", () => {
const ev = await valueChanged;
expect(ev.detail.value).toEqual(expected);
expect(notification.mock.calls[0][0].detail.id).toBe(
PASTED_CONFIG_TOAST_ID
);
}
);
});
+5 -5
View File
@@ -5641,10 +5641,10 @@ __metadata:
languageName: node
linkType: hard
"@types/luxon@npm:3.7.2":
version: 3.7.2
resolution: "@types/luxon@npm:3.7.2"
checksum: 10/6634ebaceeec84d39cbb456d4db358481bc2ba7d05df47890a4f2d235100c29a52f621e1401f016747093093fa86c117bef01446199e0b1af3bcdcee47a57b1f
"@types/luxon@npm:3.7.3":
version: 3.7.3
resolution: "@types/luxon@npm:3.7.3"
checksum: 10/f097bd3f7c47ae766928e0fa496ec13fe4d72c42b29afa4bed457775ce4fb4d8574ccccc0c974bf8915d1e6a7e226fa6efb989905786c4c9c1714754f74e8a17
languageName: node
linkType: hard
@@ -9956,7 +9956,7 @@ __metadata:
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.2"
"@types/luxon": "npm:3.7.3"
"@types/qrcode": "npm:1.5.6"
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"