mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-04 21:52:59 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b6e150def | |||
| 939b7012e2 | |||
| 488d3d9b37 | |||
| 08dedc415f | |||
| 400bf78ce0 | |||
| 9b2093125e | |||
| c0d963747a | |||
| 1bcd43fb75 | |||
| ce5925388c |
@@ -31,6 +31,33 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
Scope and public surface:
|
||||
|
||||
- Keep changes independently reviewable and limited to the requested area.
|
||||
- Prefer existing Home Assistant helpers, Lit primitives, and component seams over parallel implementations.
|
||||
- Challenge new public properties and optional feature surface when transient options or existing seams meet the requirement with less lifecycle and consistency cost.
|
||||
|
||||
Stateful and asynchronous UI:
|
||||
|
||||
- Review transitions in both directions, not only individual rendered states.
|
||||
- When controls reappear, restore valid defaults instead of retaining state that was only valid while they were hidden.
|
||||
- Establish immutable dirty-state baselines before asynchronous work, guard against stale responses, and preserve unsaved state in mounted editors.
|
||||
- Determine an action's current meaning before applying dirty-state checks, especially when an action can change between Save and Close.
|
||||
|
||||
Readiness and invalidation:
|
||||
|
||||
- Treat readiness as the first displayable terminal result, including stable empty and error states.
|
||||
- Register child readiness before resolving the parent, do not treat fallback work as terminal, and replay readiness correctly for cached or reused panels.
|
||||
- Ensure every value read by memoized output participates in its invalidation.
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
- Promote AI-review concerns into durable guidance only when supported by code evidence, reproduced behavior, an accepted corrective commit, or human-maintainer validation.
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
@@ -70,4 +97,4 @@ Configuration and props:
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-events`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
-2
@@ -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,6 +144,7 @@
|
||||
"@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",
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
>
|
||||
${
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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月");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
@@ -5641,10 +5641,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/luxon@npm:3.7.2":
|
||||
version: 3.7.2
|
||||
resolution: "@types/luxon@npm:3.7.2"
|
||||
checksum: 10/6634ebaceeec84d39cbb456d4db358481bc2ba7d05df47890a4f2d235100c29a52f621e1401f016747093093fa86c117bef01446199e0b1af3bcdcee47a57b1f
|
||||
"@types/luxon@npm:3.7.3":
|
||||
version: 3.7.3
|
||||
resolution: "@types/luxon@npm:3.7.3"
|
||||
checksum: 10/f097bd3f7c47ae766928e0fa496ec13fe4d72c42b29afa4bed457775ce4fb4d8574ccccc0c974bf8915d1e6a7e226fa6efb989905786c4c9c1714754f74e8a17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6744,13 +6744,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 +7321,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 +9766,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 +9899,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"
|
||||
@@ -9956,7 +9937,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 +9982,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 +12764,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 +12977,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 +14165,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 +14571,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