mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-04 21:52:59 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f5b8099a6 | |||
| a9cc652214 | |||
| c0d963747a | |||
| 1bcd43fb75 |
+1
-1
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user