mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-05 05:58:13 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac90befa88 | |||
| 2894113033 | |||
| b9bbef3bfc | |||
| 706382cb68 | |||
| cc17921c22 | |||
| b5ef563b71 | |||
| 1b6e150def | |||
| 939b7012e2 | |||
| 488d3d9b37 | |||
| 08dedc415f | |||
| 400bf78ce0 | |||
| 9b2093125e | |||
| c0d963747a | |||
| 1bcd43fb75 |
@@ -3,8 +3,8 @@
|
||||
import { constants } from "node:zlib";
|
||||
import gulp from "gulp";
|
||||
import brotli from "gulp-brotli";
|
||||
import zopfli from "gulp-zopfli-green";
|
||||
import paths from "../paths.cjs";
|
||||
import zopfli from "../zopfli.mjs";
|
||||
|
||||
const filesGlob = "*.{js,json,css,svg,xml}";
|
||||
const brotliOptions = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
|
||||
// compressing on the main thread blocks the event loop; one instance per worker
|
||||
// is what makes the work parallel.
|
||||
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import zopfli from "@gfx/zopfli";
|
||||
|
||||
parentPort.on("message", ({ contents, options }) => {
|
||||
zopfli.gzip(contents, options, (error, result) => {
|
||||
if (error) {
|
||||
parentPort.postMessage({ error: error.message ?? String(error) });
|
||||
return;
|
||||
}
|
||||
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
|
||||
// its ArrayBuffer and can be transferred instead of cloned.
|
||||
parentPort.postMessage({ result }, [result.buffer]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
// Gulp transform that gzips files with zopfli across a pool of worker threads.
|
||||
//
|
||||
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
|
||||
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
|
||||
// blocks the event loop for the whole compression step. Running one WASM
|
||||
// instance per worker parallelises it; the output bytes are unchanged.
|
||||
|
||||
import { availableParallelism } from "node:os";
|
||||
import { Transform } from "node:stream";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { Worker } from "node:worker_threads";
|
||||
|
||||
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
|
||||
const EXTENSION = ".gz";
|
||||
|
||||
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
|
||||
// what gulp-zopfli-green did, so compressed output stays byte-identical.
|
||||
const ZOPFLI_OPTIONS = {};
|
||||
|
||||
const poolSize = () => {
|
||||
const configured = Number(process.env.ZOPFLI_WORKERS);
|
||||
return Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: availableParallelism();
|
||||
};
|
||||
|
||||
// One job per worker at a time, so the worker itself is the job slot.
|
||||
const createPool = (size) => {
|
||||
const live = new Set();
|
||||
const idle = [];
|
||||
const waiting = [];
|
||||
const inFlight = new Map();
|
||||
|
||||
// A dead worker must leave the pool, or it gets handed a job that never
|
||||
// completes and the build hangs instead of failing.
|
||||
const retire = (worker, error) => {
|
||||
if (!live.delete(worker)) {
|
||||
return;
|
||||
}
|
||||
const index = idle.indexOf(worker);
|
||||
if (index !== -1) {
|
||||
idle.splice(index, 1);
|
||||
}
|
||||
const job = inFlight.get(worker);
|
||||
inFlight.delete(worker);
|
||||
job?.reject(error);
|
||||
waiting.shift()?.();
|
||||
};
|
||||
|
||||
const spawn = () => {
|
||||
const worker = new Worker(WORKER_URL);
|
||||
live.add(worker);
|
||||
// Idle workers must not hold the process open. Each job refs its worker for
|
||||
// as long as it runs, so a pending compression always keeps the event loop
|
||||
// alive.
|
||||
worker.unref();
|
||||
|
||||
worker.on("message", ({ error, result }) => {
|
||||
const job = inFlight.get(worker);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
inFlight.delete(worker);
|
||||
worker.unref();
|
||||
idle.push(worker);
|
||||
if (error) {
|
||||
job.reject(new Error(error));
|
||||
} else {
|
||||
job.resolve(
|
||||
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
|
||||
);
|
||||
}
|
||||
waiting.shift()?.();
|
||||
});
|
||||
|
||||
worker.on("error", (error) => retire(worker, error));
|
||||
worker.on("exit", () =>
|
||||
retire(worker, new Error("zopfli worker exited unexpectedly"))
|
||||
);
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
return (contents) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const start = () => {
|
||||
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
|
||||
if (!worker) {
|
||||
waiting.push(start);
|
||||
return;
|
||||
}
|
||||
inFlight.set(worker, { resolve, reject });
|
||||
worker.ref();
|
||||
// `contents` is cloned rather than transferred: buffers read by vinyl
|
||||
// can share a pooled ArrayBuffer with unrelated buffers, and
|
||||
// transferring would detach those too.
|
||||
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
|
||||
};
|
||||
start();
|
||||
});
|
||||
};
|
||||
|
||||
// Shared by every transform this module hands out, so the worker count is a
|
||||
// property of the process rather than of how many streams happen to run.
|
||||
let pool;
|
||||
|
||||
const sharedPool = () => {
|
||||
if (!pool) {
|
||||
const size = poolSize();
|
||||
pool = { size, compress: createPool(size) };
|
||||
}
|
||||
return pool;
|
||||
};
|
||||
|
||||
// through2 and node's Transform both wait for the previous callback before
|
||||
// handling the next file, which would serialise the pool down to one worker.
|
||||
// This keeps `limit` files in flight and applies backpressure beyond that.
|
||||
class ParallelTransform extends Transform {
|
||||
#limit;
|
||||
|
||||
#handle;
|
||||
|
||||
#inFlight = 0;
|
||||
|
||||
#resume;
|
||||
|
||||
#finish;
|
||||
|
||||
constructor(limit, handle) {
|
||||
super({ objectMode: true, highWaterMark: limit });
|
||||
this.#limit = limit;
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_transform(file, _encoding, callback) {
|
||||
this.#inFlight += 1;
|
||||
this.#handle(file)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
this.push(result);
|
||||
}
|
||||
})
|
||||
.catch((error) => this.destroy(error))
|
||||
.finally(() => {
|
||||
this.#inFlight -= 1;
|
||||
const resume = this.#resume;
|
||||
this.#resume = undefined;
|
||||
resume?.();
|
||||
if (this.#inFlight === 0) {
|
||||
const finish = this.#finish;
|
||||
this.#finish = undefined;
|
||||
finish?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.#inFlight < this.#limit) {
|
||||
callback();
|
||||
} else {
|
||||
this.#resume = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_flush(callback) {
|
||||
if (this.#inFlight === 0) {
|
||||
callback();
|
||||
} else {
|
||||
this.#finish = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.threshold] Skip files smaller than this many bytes.
|
||||
*/
|
||||
export default ({ threshold = 0 } = {}) => {
|
||||
const { size, compress } = sharedPool();
|
||||
|
||||
return new ParallelTransform(size, async (file) => {
|
||||
if (file.isNull()) {
|
||||
return file;
|
||||
}
|
||||
if (file.isStream()) {
|
||||
file.contents = await readStream(file.contents);
|
||||
}
|
||||
if (threshold && file.contents.length < threshold) {
|
||||
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
|
||||
return file;
|
||||
}
|
||||
file.contents = await compress(file.contents);
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
import "./build-scripts/gulp/index.mjs";
|
||||
|
||||
process.env.UV_THREADPOOL_SIZE = availableParallelism();
|
||||
|
||||
+4
-4
@@ -103,7 +103,6 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
@@ -145,10 +144,11 @@
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.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",
|
||||
|
||||
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
|
||||
|
||||
return `${startDate}/${endDate}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* August 2021, for calendar days coming out of cally.
|
||||
*
|
||||
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
|
||||
* formats its own headings in UTC too. Anything derived from a cally event has
|
||||
* to be formatted the same way: the resolved time zone shifts the day back for
|
||||
* negative UTC offsets, which becomes a wrong month — and in January a wrong
|
||||
* year — whenever the day is the 1st.
|
||||
*/
|
||||
export const formatCallyMonthYear = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData
|
||||
) => formatCallyMonthYearMem(locale.language).format(dateObj);
|
||||
|
||||
const formatCallyMonthYearMem = memoizeOne(
|
||||
(language: string) =>
|
||||
new Intl.DateTimeFormat(language, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ export class HaInputChip extends InputChip {
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
max-width: 100%;
|
||||
--md-sys-color-primary: var(--primary-text-color);
|
||||
--md-sys-color-on-surface: var(--primary-text-color);
|
||||
--md-sys-color-on-surface-variant: var(--primary-text-color);
|
||||
|
||||
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
|
||||
import {
|
||||
formatCallyDateRange,
|
||||
formatDateMonth,
|
||||
formatDateYear,
|
||||
formatCallyMonthYear,
|
||||
formatDateMonthYear,
|
||||
formatISODateOnly,
|
||||
} from "../../common/datetime/format_date";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
@@ -60,13 +60,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
|
||||
/** used to show month in calendar-range header */
|
||||
@state() private _pickerMonth?: string;
|
||||
/** used to show month and year in calendar-range header */
|
||||
@state() private _pickerMonthYear?: string;
|
||||
|
||||
/** used to show year in calendar-date header */
|
||||
@state() private _pickerYear?: string;
|
||||
|
||||
/** used for today to navigate focus in calendar-range */
|
||||
/**
|
||||
* used for today to navigate focus in calendar-range
|
||||
*
|
||||
* Always mirrors the day calendar-range has focused, never cleared: once this
|
||||
* has held a date, rendering `undefined` over it makes cally fall back to the
|
||||
* selected range and page the calendar away from where the user is.
|
||||
*/
|
||||
@state() private _focusDate?: string;
|
||||
|
||||
@state() private _dateValue?: string;
|
||||
@@ -92,12 +95,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
this._hassConfig
|
||||
)
|
||||
: undefined;
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -167,9 +165,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
slot="previous"
|
||||
></ha-icon-button-prev>
|
||||
<div class="heading" slot="heading">
|
||||
<span class="month-year"
|
||||
>${this._pickerMonth} ${this._pickerYear}</span
|
||||
>
|
||||
<span class="month-year">${this._pickerMonthYear}</span>
|
||||
<ha-icon-button
|
||||
@click=${this._focusToday}
|
||||
.path=${mdiCalendarToday}
|
||||
@@ -233,12 +229,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -311,19 +302,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _focusChanged(ev: HASSDomEvent<Date>) {
|
||||
const date = ev.detail;
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._focusDate = undefined;
|
||||
private _focusChanged(
|
||||
ev: HASSDomEvent<Date> &
|
||||
HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
|
||||
) {
|
||||
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
|
||||
this._focusDate = ev.target.focusedDate;
|
||||
}
|
||||
|
||||
private _handleChange(
|
||||
@@ -331,7 +315,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
|
||||
this._dateValue = dateElement.value;
|
||||
this._focusDate = undefined;
|
||||
this._focusDate = dateElement.focusedDate;
|
||||
}
|
||||
|
||||
private _clickDateRangeChip(
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import {
|
||||
formatDateMonth,
|
||||
formatCallyMonthYear,
|
||||
formatDateMonthYear,
|
||||
formatDateShort,
|
||||
formatDateYear,
|
||||
formatISODateOnly,
|
||||
@@ -60,13 +61,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
dateString: string;
|
||||
};
|
||||
|
||||
/** used to show month in calendar-date header */
|
||||
@state() private _pickerMonth?: string;
|
||||
/** used to show month and year in calendar-date header */
|
||||
@state() private _pickerMonthYear?: string;
|
||||
|
||||
/** used to show year in calendar-date header */
|
||||
@state() private _pickerYear?: string;
|
||||
|
||||
/** used for today to navigate focus in cally-calendar-date */
|
||||
/**
|
||||
* used for today to navigate focus in cally-calendar-date
|
||||
*
|
||||
* Always mirrors the day calendar-date has focused, never cleared: once this
|
||||
* has held a date, rendering `undefined` over it makes cally fall back to the
|
||||
* selected value and page the calendar away from where the user is.
|
||||
*/
|
||||
@state() private _focusDate?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -75,12 +79,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
if (this.params) {
|
||||
const date = valueToDate(this.params.value);
|
||||
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerMonth = formatDateMonth(
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -88,7 +87,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
|
||||
this._value = this.params.value
|
||||
? {
|
||||
year: this._pickerYear,
|
||||
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
|
||||
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
|
||||
dateString: formatISODateOnly(
|
||||
date,
|
||||
@@ -144,9 +143,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
slot="previous"
|
||||
></ha-icon-button-prev>
|
||||
<div class="heading" slot="heading">
|
||||
<span class="month-year"
|
||||
>${this._pickerMonth} ${this._pickerYear}</span
|
||||
>
|
||||
<span class="month-year">${this._pickerMonthYear}</span>
|
||||
<ha-icon-button
|
||||
@click=${this._setToday}
|
||||
.path=${mdiCalendarToday}
|
||||
@@ -189,12 +186,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
|
||||
if (setFocusDay) {
|
||||
this._focusDate = this._value.dateString;
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
this._pickerMonthYear = formatDateMonthYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
@@ -202,19 +194,11 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
|
||||
}
|
||||
}
|
||||
|
||||
private _focusChanged(ev: HASSDomEvent<Date>) {
|
||||
const date = ev.detail;
|
||||
this._pickerMonth = formatDateMonth(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._pickerYear = formatDateYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
this._focusDate = undefined;
|
||||
private _focusChanged(
|
||||
ev: HASSDomEvent<Date> & HASSDomTargetEvent<CalendarDate>
|
||||
) {
|
||||
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
|
||||
this._focusDate = ev.target.focusedDate;
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -187,6 +187,7 @@ export class HaSelectSelector extends LitElement {
|
||||
.idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
.label=${label}
|
||||
.title=${label}
|
||||
selected
|
||||
>
|
||||
${
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ export interface LovelaceViewFooterConfig {
|
||||
|
||||
export interface LovelaceViewSidebarConfig {
|
||||
sections?: LovelaceSectionConfig[];
|
||||
content_label?: string;
|
||||
sidebar_label?: string;
|
||||
visibility?: Condition[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -317,7 +317,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
color:
|
||||
automation.state === UNAVAILABLE
|
||||
? "var(--error-color)"
|
||||
: "unset",
|
||||
: automation.state === "on"
|
||||
? "var(--state-active-color)"
|
||||
: "unset",
|
||||
})}
|
||||
></ha-state-icon>`,
|
||||
},
|
||||
@@ -346,19 +348,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.formatted_state
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -68,6 +68,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/input/ha-input";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
@@ -234,7 +235,7 @@ export class DialogEnergySolarSettings
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>${entry.title}
|
||||
/>${entry.title || domainToName(this.hass.localize, entry.domain)}
|
||||
</div>
|
||||
</ha-checkbox>`
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -149,6 +149,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
this._yAxisFractionDigits,
|
||||
this._legendData
|
||||
)}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
click-label-for-more-info
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
|
||||
@@ -192,6 +192,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
)}
|
||||
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
|
||||
.extraComponents=${[PieChart]}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
click-label-for-more-info
|
||||
@chart-click=${this._handleChartClick}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
|
||||
@@ -177,6 +177,7 @@ export class HuiEnergyUsageGraphCard
|
||||
this._legendData
|
||||
)}
|
||||
chart-type="bar"
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
></ha-chart-base>
|
||||
${
|
||||
!this._chartData.some((dataset) => dataset.data!.length)
|
||||
|
||||
@@ -121,6 +121,7 @@ export class HuiPowerSourcesGraphCard
|
||||
this._legendData,
|
||||
this._yAxisFractionDigits
|
||||
)}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
></ha-chart-base>
|
||||
${
|
||||
!this._chartData.some((dataset) => dataset.data!.length)
|
||||
|
||||
@@ -189,6 +189,7 @@ export interface EnergyDistributionCardConfig extends EnergyCardConfig {
|
||||
export interface EnergyUsageGraphCardConfig extends EnergyCardConfig {
|
||||
type: "energy-usage-graph";
|
||||
show_legend?: boolean;
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySolarGraphCardConfig extends EnergyCardConfig {
|
||||
@@ -208,11 +209,13 @@ export interface EnergyDevicesGraphCardConfig extends EnergyCardConfig {
|
||||
max_devices?: number;
|
||||
hide_compound_stats?: boolean;
|
||||
modes?: ("bar" | "pie")[];
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergyDevicesDetailGraphCardConfig extends EnergyCardConfig {
|
||||
type: "energy-devices-detail-graph";
|
||||
max_devices?: number;
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySourcesTableCardConfig extends EnergyCardConfig {
|
||||
@@ -244,6 +247,7 @@ export interface EnergyCarbonGaugeCardConfig extends EnergyCardConfig {
|
||||
export interface PowerSourcesGraphCardConfig extends EnergyCardConfig {
|
||||
type: "power-sources-graph";
|
||||
show_legend?: boolean;
|
||||
expand_legend?: boolean;
|
||||
}
|
||||
|
||||
export interface EnergySankeyCardConfig extends EnergyCardSankeyConfig {
|
||||
|
||||
@@ -38,6 +38,7 @@ const cardConfigStruct = assign(
|
||||
max_devices: optional(number()),
|
||||
modes: optional(array(union([literal("bar"), literal("pie")]))),
|
||||
hide_compound_stats: optional(boolean()),
|
||||
expand_legend: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -91,6 +92,11 @@ export class HuiEnergyDevicesCardEditor
|
||||
visible: { field: "type", value: "energy-devices-graph" },
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "max_devices",
|
||||
required: false,
|
||||
|
||||
@@ -40,6 +40,19 @@ const SCHEMA: HaFormSchema[] = [
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
visible: [
|
||||
{
|
||||
field: "type",
|
||||
operator: "in",
|
||||
value: ["power-sources-graph", "energy-usage-graph"],
|
||||
},
|
||||
{ field: "show_legend", operator: "not_eq", value: false },
|
||||
],
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "link_dashboard",
|
||||
visible: { field: "type", value: "energy-distribution" },
|
||||
@@ -73,6 +86,7 @@ const cardConfigStruct = assign(
|
||||
title: optional(string()),
|
||||
collection_key: optional(string()),
|
||||
show_legend: optional(boolean()),
|
||||
expand_legend: optional(boolean()),
|
||||
link_dashboard: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -587,10 +587,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
...(sidebarSection && {
|
||||
sidebar: {
|
||||
sections: [sidebarSection],
|
||||
content_label: hass.localize("ui.panel.lovelace.strategy.home.home"),
|
||||
sidebar_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.summaries"
|
||||
),
|
||||
visibility: [LARGE_SCREEN_CONDITION],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -72,14 +72,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
@state() _dragging = false;
|
||||
|
||||
@state() private _sidebarTabActive = false;
|
||||
|
||||
@state() private _sidebarVisible = true;
|
||||
|
||||
private _contentScrollTop = 0;
|
||||
|
||||
private _sidebarScrollTop = 0;
|
||||
|
||||
private _columnsController = new ResizeController(this, {
|
||||
callback: (entries) => {
|
||||
const totalWidth = entries[0]?.contentRect.width;
|
||||
@@ -135,7 +129,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
"section-visibility-changed",
|
||||
this._sectionVisibilityChanged
|
||||
);
|
||||
this._sidebarTabActive = Boolean(window.history.state?.sidebar);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -184,7 +177,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
Math.min(this._maxColumns, totalSectionCount),
|
||||
1
|
||||
);
|
||||
// On mobile with sidebar, use full width for whichever view is active
|
||||
// On mobile the sidebar stacks below the content, so both use full width
|
||||
const contentColumnCount =
|
||||
hasSidebar && !this.narrow ? Math.max(1, columnCount - 1) : columnCount;
|
||||
|
||||
@@ -212,29 +205,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
.viewIndex=${this.index}
|
||||
.config=${this._config?.header}
|
||||
></hui-view-header>
|
||||
${
|
||||
this.narrow && hasSidebar
|
||||
? html`
|
||||
<div class="mobile-tabs">
|
||||
<ha-control-select
|
||||
.value=${this._sidebarTabActive ? "sidebar" : "content"}
|
||||
@value-changed=${this._viewChanged}
|
||||
.options=${[
|
||||
{
|
||||
value: "content",
|
||||
label: this._config!.sidebar!.content_label,
|
||||
},
|
||||
{
|
||||
value: "sidebar",
|
||||
label: this._config!.sidebar!.sidebar_label,
|
||||
},
|
||||
]}
|
||||
>
|
||||
</ha-control-select>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="container">
|
||||
<ha-sortable
|
||||
.disabled=${!editMode}
|
||||
@@ -246,7 +216,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
<div
|
||||
class="content ${classMap({
|
||||
dense: Boolean(this._config?.dense_section_placement),
|
||||
"mobile-hidden": this.narrow && this._sidebarTabActive,
|
||||
})}"
|
||||
>
|
||||
${repeat(
|
||||
@@ -333,8 +302,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
? html`
|
||||
<hui-view-sidebar
|
||||
class=${classMap({
|
||||
"mobile-hidden":
|
||||
!hasSidebar || (this.narrow && !this._sidebarTabActive),
|
||||
"sidebar-hidden": !hasSidebar,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
.badges=${this.badges}
|
||||
@@ -474,48 +442,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
this.lovelace!.saveConfig(newConfig);
|
||||
}
|
||||
|
||||
private _viewChanged(ev: CustomEvent) {
|
||||
const newValue = ev.detail.value;
|
||||
const shouldShowSidebar = newValue === "sidebar";
|
||||
|
||||
if (shouldShowSidebar !== this._sidebarTabActive) {
|
||||
this._toggleView();
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleView() {
|
||||
// Save current scroll position
|
||||
if (this._sidebarTabActive) {
|
||||
this._sidebarScrollTop = window.scrollY;
|
||||
} else {
|
||||
this._contentScrollTop = window.scrollY;
|
||||
}
|
||||
|
||||
this._sidebarTabActive = !this._sidebarTabActive;
|
||||
|
||||
// Add sidebar state to history
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sidebar: this._sidebarTabActive },
|
||||
""
|
||||
);
|
||||
|
||||
// Restore scroll position after view updates
|
||||
this.updateComplete.then(() => {
|
||||
const scrollY = this._sidebarTabActive
|
||||
? this._sidebarScrollTop
|
||||
: this._contentScrollTop;
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSidebarVisibilityChanged = (
|
||||
e: CustomEvent<{ visible: boolean }>
|
||||
) => {
|
||||
this._sidebarVisible = e.detail.visible;
|
||||
// Reset sidebar tab when sidebar becomes hidden
|
||||
if (!e.detail.visible) {
|
||||
this._sidebarTabActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
@@ -613,37 +543,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
|
||||
.wrapper.narrow hui-view-sidebar {
|
||||
grid-column: 1 / -1;
|
||||
padding-bottom: calc(
|
||||
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
.sidebar-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-tabs {
|
||||
position: fixed;
|
||||
bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mobile-tabs ha-control-select {
|
||||
width: max-content;
|
||||
min-width: 280px;
|
||||
max-width: 90%;
|
||||
--control-select-thickness: var(--ha-space-14);
|
||||
--control-select-border-radius: var(--ha-border-radius-pill);
|
||||
--control-select-background: var(--card-background-color);
|
||||
--control-select-background-opacity: 1;
|
||||
--control-select-color: var(--primary-color);
|
||||
--control-select-padding: 6px;
|
||||
box-shadow: rgba(0, 0, 0, 0.3) 0px 4px 10px 0px;
|
||||
}
|
||||
|
||||
ha-sortable {
|
||||
display: contents;
|
||||
}
|
||||
@@ -663,12 +568,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.wrapper.narrow.has-sidebar .content {
|
||||
padding-bottom: calc(
|
||||
var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
|
||||
.content.dense {
|
||||
grid-auto-flow: row dense;
|
||||
}
|
||||
|
||||
@@ -273,12 +273,6 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
...(sidebarSection && {
|
||||
sidebar: {
|
||||
sections: [sidebarSection],
|
||||
content_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.devices"
|
||||
),
|
||||
sidebar_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -9000,7 +9000,6 @@
|
||||
"scenes": "Scenes",
|
||||
"automations": "Automations",
|
||||
"for_you": "For you",
|
||||
"home": "Home",
|
||||
"favorites": "Favorites",
|
||||
"welcome_title": "No devices here yet",
|
||||
"welcome_content": "Add lights, switches, sensors, or other smart home devices to get started.",
|
||||
@@ -10095,6 +10094,7 @@
|
||||
"double_tap_action": "Double tap behavior",
|
||||
"entities": "Entities",
|
||||
"entity": "Entity",
|
||||
"expand_legend": "Expand legend",
|
||||
"fit_mode": "Fit mode",
|
||||
"fit_mode_options": {
|
||||
"contain": "Scaled (Contain)",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import process from "node:process";
|
||||
import { Readable } from "node:stream";
|
||||
import gfxZopfli from "@gfx/zopfli";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Keep the pool small; it is created once, on the first factory call.
|
||||
process.env.ZOPFLI_WORKERS = "2";
|
||||
|
||||
const { default: zopfli } = await import("../../build-scripts/zopfli.mjs");
|
||||
|
||||
const THRESHOLD = 150;
|
||||
|
||||
// Stand-in for the vinyl files gulp.src yields in buffer mode.
|
||||
const file = (path, contents) => ({
|
||||
path,
|
||||
contents,
|
||||
isNull() {
|
||||
return this.contents === null;
|
||||
},
|
||||
isStream() {
|
||||
return this.contents instanceof Readable;
|
||||
},
|
||||
});
|
||||
|
||||
const run = (files, options = { threshold: THRESHOLD }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const stream = zopfli(options);
|
||||
const out = [];
|
||||
stream.on("data", (result) => out.push(result));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(out));
|
||||
files.forEach((entry) => stream.write(entry));
|
||||
stream.end();
|
||||
});
|
||||
|
||||
// What the old in-process plugin did, for byte-for-byte comparison.
|
||||
const gzipInProcess = (contents) =>
|
||||
new Promise((resolve, reject) => {
|
||||
gfxZopfli.gzip(contents, {}, (error, result) =>
|
||||
error ? reject(error) : resolve(Buffer.from(result))
|
||||
);
|
||||
});
|
||||
|
||||
const filler = (length) => Buffer.alloc(length, "a");
|
||||
|
||||
describe("zopfli worker pool", () => {
|
||||
it("appends .gz and matches in-process zopfli byte for byte", async () => {
|
||||
const contents = filler(4096);
|
||||
const [result] = await run([file("/out/app.js", contents)]);
|
||||
|
||||
expect(result.path).toBe("/out/app.js.gz");
|
||||
expect(result.contents).toEqual(await gzipInProcess(contents));
|
||||
});
|
||||
|
||||
it("returns contents as a Buffer, which vinyl requires", async () => {
|
||||
const [result] = await run([file("/out/app.js", filler(1024))]);
|
||||
|
||||
expect(Buffer.isBuffer(result.contents)).toBe(true);
|
||||
});
|
||||
|
||||
it("appends to the path rather than replacing the extension", async () => {
|
||||
const [result] = await run([file("/out/nested/chunk.min.js", filler(500))]);
|
||||
|
||||
expect(result.path).toBe("/out/nested/chunk.min.js.gz");
|
||||
});
|
||||
|
||||
it("passes files under the threshold through untouched", async () => {
|
||||
const contents = filler(THRESHOLD - 1);
|
||||
const [result] = await run([file("/out/tiny.js", contents)]);
|
||||
|
||||
expect(result.path).toBe("/out/tiny.js");
|
||||
expect(result.contents).toBe(contents);
|
||||
});
|
||||
|
||||
it("compresses a file exactly at the threshold", async () => {
|
||||
const [result] = await run([file("/out/edge.js", filler(THRESHOLD))]);
|
||||
|
||||
expect(result.path).toBe("/out/edge.js.gz");
|
||||
});
|
||||
|
||||
it("compresses everything when no threshold is given", async () => {
|
||||
const [result] = await run([file("/out/tiny.js", filler(1))], {});
|
||||
|
||||
expect(result.path).toBe("/out/tiny.js.gz");
|
||||
});
|
||||
|
||||
it("passes null files through", async () => {
|
||||
const [result] = await run([file("/out/adirectory", null)]);
|
||||
|
||||
expect(result.path).toBe("/out/adirectory");
|
||||
expect(result.contents).toBeNull();
|
||||
});
|
||||
|
||||
it("compresses only the requested slice of a pooled buffer", async () => {
|
||||
// Small reads share a pooled ArrayBuffer, so contents often start at a
|
||||
// non-zero byteOffset into a much larger allocation.
|
||||
const pool = Buffer.alloc(8192, "z");
|
||||
const contents = pool.subarray(1000, 1000 + 512);
|
||||
contents.fill("a");
|
||||
|
||||
const [result] = await run([file("/out/pooled.js", contents)]);
|
||||
|
||||
expect(result.contents).toEqual(await gzipInProcess(filler(512)));
|
||||
});
|
||||
|
||||
it("buffers stream-mode contents", async () => {
|
||||
const contents = filler(600);
|
||||
const [result] = await run([
|
||||
file("/out/streamed.js", Readable.from([contents])),
|
||||
]);
|
||||
|
||||
expect(result.path).toBe("/out/streamed.js.gz");
|
||||
expect(result.contents).toEqual(await gzipInProcess(contents));
|
||||
});
|
||||
|
||||
it("handles more files than the pool has workers", async () => {
|
||||
const count = 25;
|
||||
const files = Array.from({ length: count }, (_, index) =>
|
||||
file(`/out/chunk${index}.js`, filler(200 + index))
|
||||
);
|
||||
|
||||
const results = await run(files);
|
||||
|
||||
expect(results).toHaveLength(count);
|
||||
expect(new Set(results.map((result) => result.path)).size).toBe(count);
|
||||
await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const index = Number(result.path.match(/chunk(\d+)/)[1]);
|
||||
expect(result.contents).toEqual(
|
||||
await gzipInProcess(filler(200 + index))
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("serves two concurrent streams from the same pool", async () => {
|
||||
const [first, second] = await Promise.all([
|
||||
run([file("/out/a.js", filler(300))]),
|
||||
run([file("/out/b.js", filler(400))]),
|
||||
]);
|
||||
|
||||
expect(first[0].path).toBe("/out/a.js.gz");
|
||||
expect(second[0].path).toBe("/out/b.js.gz");
|
||||
expect(first[0].contents).toEqual(await gzipInProcess(filler(300)));
|
||||
expect(second[0].contents).toEqual(await gzipInProcess(filler(400)));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCallyMonthYear,
|
||||
formatDate,
|
||||
formatDateWeekdayDay,
|
||||
formatDateShort,
|
||||
@@ -263,4 +264,43 @@ describe("formatDate", () => {
|
||||
).toBe("Sat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCallyMonthYear", () => {
|
||||
// How cally hands out a calendar day: a Date anchored to UTC midnight
|
||||
const julyFirst = new Date(Date.UTC(2026, 6, 1));
|
||||
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
|
||||
|
||||
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
|
||||
// negative UTC offset whatever zone the tests run in
|
||||
const locale = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
|
||||
it("Formats in UTC rather than the resolved time zone", () => {
|
||||
// A time zone aware formatter reads UTC midnight on the 1st as the
|
||||
// previous month, which desyncs the header from the calendar grid
|
||||
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
|
||||
"June 2026"
|
||||
);
|
||||
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
|
||||
});
|
||||
|
||||
it("Keeps the year on a January page", () => {
|
||||
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
|
||||
"December 2025"
|
||||
);
|
||||
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
|
||||
});
|
||||
|
||||
it("Orders month and year by locale", () => {
|
||||
expect(
|
||||
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
|
||||
).toBe("2026年7月");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2945,7 +2945,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@gfx/zopfli@npm:^1.0.15":
|
||||
"@gfx/zopfli@npm:1.0.15":
|
||||
version: 1.0.15
|
||||
resolution: "@gfx/zopfli@npm:1.0.15"
|
||||
dependencies:
|
||||
@@ -4032,15 +4032,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/auth-oauth-device@npm:8.0.3":
|
||||
version: 8.0.3
|
||||
resolution: "@octokit/auth-oauth-device@npm:8.0.3"
|
||||
"@octokit/auth-oauth-device@npm:8.0.4":
|
||||
version: 8.0.4
|
||||
resolution: "@octokit/auth-oauth-device@npm:8.0.4"
|
||||
dependencies:
|
||||
"@octokit/oauth-methods": "npm:^6.0.2"
|
||||
"@octokit/request": "npm:^10.0.6"
|
||||
"@octokit/types": "npm:^16.0.0"
|
||||
"@octokit/oauth-methods": "npm:^6.0.3"
|
||||
"@octokit/request": "npm:^10.0.13"
|
||||
"@octokit/types": "npm:^17.0.0"
|
||||
universal-user-agent: "npm:^7.0.0"
|
||||
checksum: 10/33b9df5ebe971226b44bfe876ba5c188c687804b6efbdca980c2e78106fbd713439d73edc451331c7ea0095b09149a8f47e0b727e6c90d78b94dc7e4caf1aa3c
|
||||
checksum: 10/1ea5a7844f98d3f92a303e84984e5fa568253ccb1acfe1ca76d256a0e94d220978c8645d2e5206e1bd55c318511dec6aac864b4156751113beeb24c435eb935a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4094,15 +4094,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/oauth-methods@npm:^6.0.2":
|
||||
version: 6.0.2
|
||||
resolution: "@octokit/oauth-methods@npm:6.0.2"
|
||||
"@octokit/oauth-methods@npm:^6.0.3":
|
||||
version: 6.0.3
|
||||
resolution: "@octokit/oauth-methods@npm:6.0.3"
|
||||
dependencies:
|
||||
"@octokit/oauth-authorization-url": "npm:^8.0.0"
|
||||
"@octokit/request": "npm:^10.0.6"
|
||||
"@octokit/request-error": "npm:^7.0.2"
|
||||
"@octokit/types": "npm:^16.0.0"
|
||||
checksum: 10/b5a9f9d361f797595e188d6e8c0286f82030df9cf6bf81023248034b85315a1180d0710aed3c255c5399f418d271a4489b169becba4b2294525a689cade85872
|
||||
"@octokit/request": "npm:^10.0.13"
|
||||
"@octokit/request-error": "npm:^7.1.1"
|
||||
"@octokit/types": "npm:^17.0.0"
|
||||
checksum: 10/cfa36ce9c2771e1791211ff48c80c469eef618b5be4c3ac74b51692fcf1189637da68b3849ed5d2e2193bdcea30b7df4f3de56b7906a31cf3b5ccbab500f93db
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4113,6 +4113,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/openapi-types@npm:^28.0.0":
|
||||
version: 28.0.0
|
||||
resolution: "@octokit/openapi-types@npm:28.0.0"
|
||||
checksum: 10/031cebd35b7a110771ab9909d4d2f8ac2dba7185b55ea2ba91dfa0736f397797c768b7c12b12290e9cb42f1dd1d157ffe829d2009be8542e227780e588a40c68
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/plugin-paginate-rest@npm:^14.0.0":
|
||||
version: 14.0.0
|
||||
resolution: "@octokit/plugin-paginate-rest@npm:14.0.0"
|
||||
@@ -4144,39 +4151,39 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/plugin-retry@npm:8.1.0":
|
||||
version: 8.1.0
|
||||
resolution: "@octokit/plugin-retry@npm:8.1.0"
|
||||
"@octokit/plugin-retry@npm:8.1.1":
|
||||
version: 8.1.1
|
||||
resolution: "@octokit/plugin-retry@npm:8.1.1"
|
||||
dependencies:
|
||||
"@octokit/request-error": "npm:^7.0.2"
|
||||
"@octokit/types": "npm:^16.0.0"
|
||||
"@octokit/request-error": "npm:^7.1.1"
|
||||
"@octokit/types": "npm:^17.0.0"
|
||||
bottleneck: "npm:^2.15.3"
|
||||
peerDependencies:
|
||||
"@octokit/core": ">=7"
|
||||
checksum: 10/0bccaa14ef295ac5dc3e6fa96bb7c555b8b188dfe0bf1db5ea83acb29af375bf08a43e0d44c42941608afc6ab414b6dcdfb44881a8e8346b963d7501e0aea766
|
||||
checksum: 10/ee42b70c04b6749362093bf79d3b5f4019a3a5e3cdf79e28e9d781e4a43242aa67781d1e71dba069646ea6a0b442e155403877493cd3e194e7045386b26023d7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/request-error@npm:^7.0.2":
|
||||
version: 7.1.0
|
||||
resolution: "@octokit/request-error@npm:7.1.0"
|
||||
"@octokit/request-error@npm:^7.0.2, @octokit/request-error@npm:^7.1.1":
|
||||
version: 7.1.1
|
||||
resolution: "@octokit/request-error@npm:7.1.1"
|
||||
dependencies:
|
||||
"@octokit/types": "npm:^16.0.0"
|
||||
checksum: 10/c1d447ff7482382c69f7a4b2eaa44c672906dd111d8a9196a5d07f2adc4ae0f0e12ec4ce0063f14f9b2fb5f0cef4451c95ec961a7a711bd900e5d6441d546570
|
||||
"@octokit/types": "npm:^17.0.0"
|
||||
checksum: 10/78f91331f523e004e6e1b505bfe907aede05cd8da25abb16c62df40c31e4dceed5d979a6f30de0c088f7b03e7676495af5e282c54320ad8805864e6ee82d02f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/request@npm:^10.0.6":
|
||||
version: 10.0.11
|
||||
resolution: "@octokit/request@npm:10.0.11"
|
||||
"@octokit/request@npm:^10.0.13, @octokit/request@npm:^10.0.6":
|
||||
version: 10.0.13
|
||||
resolution: "@octokit/request@npm:10.0.13"
|
||||
dependencies:
|
||||
"@octokit/endpoint": "npm:^11.0.3"
|
||||
"@octokit/request-error": "npm:^7.0.2"
|
||||
"@octokit/types": "npm:^16.0.0"
|
||||
"@octokit/request-error": "npm:^7.1.1"
|
||||
"@octokit/types": "npm:^17.0.0"
|
||||
content-type: "npm:^2.0.0"
|
||||
json-with-bigint: "npm:^3.5.3"
|
||||
universal-user-agent: "npm:^7.0.2"
|
||||
checksum: 10/036640f49e4ab63470130dccafb828822a18cf9ce060b8170e56ba9ce7173029db92b3c37ad7474acf2c097180d5127754b69d3ffbe60162489aed4324b3cb8f
|
||||
checksum: 10/6edce2ab7c5cbb29c65374929f5342c00445a6b0bb4788b2da23bbfe6844456949f5fcc3f1b17b955902215723836b72fa474e31d051df98f4f90dfb7c62ebe5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4201,6 +4208,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/types@npm:^17.0.0":
|
||||
version: 17.0.0
|
||||
resolution: "@octokit/types@npm:17.0.0"
|
||||
dependencies:
|
||||
"@octokit/openapi-types": "npm:^28.0.0"
|
||||
checksum: 10/38db90d8a2dde153b84a26b6ba2aa4e4a8ddda03dd13d6b4a6f075ffe850c946b6852ba77457e4de8c07b10d06f59d174223e69243c88ecb5cf6280ee541601e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@oxc-project/types@npm:=0.139.0":
|
||||
version: 0.139.0
|
||||
resolution: "@oxc-project/types@npm:0.139.0"
|
||||
@@ -5641,10 +5657,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
|
||||
|
||||
@@ -6744,13 +6760,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"any-promise@npm:^1.1.0":
|
||||
version: 1.3.0
|
||||
resolution: "any-promise@npm:1.3.0"
|
||||
checksum: 10/6737469ba353b5becf29e4dc3680736b9caa06d300bda6548812a8fee63ae7d336d756f88572fa6b5219aed36698d808fa55f62af3e7e6845c7a1dc77d240edb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2":
|
||||
version: 3.1.3
|
||||
resolution: "anymatch@npm:3.1.3"
|
||||
@@ -7328,7 +7337,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
|
||||
"bytes@npm:3.1.2":
|
||||
version: 3.1.2
|
||||
resolution: "bytes@npm:3.1.2"
|
||||
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
|
||||
@@ -9773,19 +9782,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gulp-zopfli-green@npm:7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "gulp-zopfli-green@npm:7.0.0"
|
||||
dependencies:
|
||||
"@gfx/zopfli": "npm:^1.0.15"
|
||||
bytes: "npm:^3.1.2"
|
||||
plugin-error: "npm:^2.0.1"
|
||||
stream-to-array: "npm:^2.3.0"
|
||||
through2: "npm:^4.0.2"
|
||||
checksum: 10/25db42b9a8f6395847e3cc06c548c3f3d51c84606166f18de71d91d505e7293176b45b4b78b7e0b1566c26263fe0dc212e56d2b367461c3d712bee9d22d5224f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gulp@npm:5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "gulp@npm:5.0.1"
|
||||
@@ -9919,6 +9915,7 @@ __metadata:
|
||||
"@fullcalendar/list": "npm:6.1.21"
|
||||
"@fullcalendar/luxon3": "npm:6.1.21"
|
||||
"@fullcalendar/timegrid": "npm:6.1.21"
|
||||
"@gfx/zopfli": "npm:1.0.15"
|
||||
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
|
||||
"@html-eslint/eslint-plugin": "npm:0.64.0"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
@@ -9934,8 +9931,8 @@ __metadata:
|
||||
"@material/web": "npm:2.5.0"
|
||||
"@mdi/js": "npm:7.4.47"
|
||||
"@mdi/svg": "npm:7.4.47"
|
||||
"@octokit/auth-oauth-device": "npm:8.0.3"
|
||||
"@octokit/plugin-retry": "npm:8.1.0"
|
||||
"@octokit/auth-oauth-device": "npm:8.0.4"
|
||||
"@octokit/plugin-retry": "npm:8.1.1"
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.62.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
@@ -9956,7 +9953,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"
|
||||
@@ -10001,7 +9998,6 @@ __metadata:
|
||||
gulp-brotli: "npm:3.0.0"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
gulp-rename: "npm:2.1.0"
|
||||
gulp-zopfli-green: "npm:7.0.0"
|
||||
hls.js: "npm:1.6.16"
|
||||
home-assistant-js-websocket: "npm:9.6.0"
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
@@ -12784,15 +12780,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"plugin-error@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "plugin-error@npm:2.0.1"
|
||||
dependencies:
|
||||
ansi-colors: "npm:^1.0.1"
|
||||
checksum: 10/9a4f91461cd24cce401112098969991d7aa6b4c94f78e0381234280c07da779570a8b21ab143292b534ec0117c09705a67e5d756c1c303d4706fdd7f861bf5bc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pngjs@npm:^3.0.0":
|
||||
version: 3.4.0
|
||||
resolution: "pngjs@npm:3.4.0"
|
||||
@@ -13006,7 +12993,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.4.0":
|
||||
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
|
||||
version: 3.6.2
|
||||
resolution: "readable-stream@npm:3.6.2"
|
||||
dependencies:
|
||||
@@ -14194,15 +14181,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"stream-to-array@npm:^2.3.0":
|
||||
version: 2.3.0
|
||||
resolution: "stream-to-array@npm:2.3.0"
|
||||
dependencies:
|
||||
any-promise: "npm:^1.1.0"
|
||||
checksum: 10/7feaf63b38399b850615e6ffcaa951e96e4c8f46745dbce4b553a94c5dc43966933813747014935a3ff97793e7f30a65270bde19f82b2932871a1879229a77cf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0":
|
||||
version: 2.28.0
|
||||
resolution: "streamx@npm:2.28.0"
|
||||
@@ -14609,15 +14587,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"through2@npm:^4.0.2":
|
||||
version: 4.0.2
|
||||
resolution: "through2@npm:4.0.2"
|
||||
dependencies:
|
||||
readable-stream: "npm:3"
|
||||
checksum: 10/72c246233d9a989bbebeb6b698ef0b7b9064cb1c47930f79b25d87b6c867e075432811f69b7b2ac8da00ca308191c507bdab913944be8019ac43b036ce88f6ba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"time-stamp@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "time-stamp@npm:1.1.0"
|
||||
|
||||
Reference in New Issue
Block a user