Compare commits

..

27 Commits

Author SHA1 Message Date
Bram Kragten ffa7cf17a6 Bumped version to 20260729.4 2026-08-04 13:19:17 +02:00
karwosts 5a5cec9060 Fix slow loading of entity rows in device page (#53471)
Fix lazy loading of entity rows in device page
2026-08-04 12:50:36 +02:00
Petar Petrov bb962ba3d8 Fix energy default period being saved under the wrong storage key (#53462) 2026-08-04 12:50:35 +02:00
Matthias Alphart d4a59ff1c7 Drop websocket collections created by an iframe panel on teardown (#53448)
* Drop websocket collections created by an iframe panel on teardown

`getCollection()` caches collections (entity registry, label registry, ...) on
the `Connection` object. A custom panel embedded in an iframe shares the
connection with the main window, so a collection the panel is the first to
request is created inside the iframe's realm.

When the user leaves the panel, `ha-panel-custom` removes the iframe and that
realm is destroyed - but the collection stays cached on the connection. Its
store, and the `setTimeout` that hands the cached state to new subscribers, are
gone, so every later subscriber waits forever for a callback that can never
fire. Since the registries moved to `LazyContextProvider` they are subscribed
on demand, which makes an iframe panel the first requester far more often: the
KNX panel's entity table renders empty after leaving and re-entering it, and
the main frontend can inherit the same dead cache.

Clean up from the iframe's own `pagehide` handler, while the realm is still
alive - `disconnectedCallback` on `ha-panel-custom` runs after the browsing
context is discarded, when `contentWindow` is already `null`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use unknown instead of any in the CachedCollection shape

The helper never inspects the subscribed state, so the parameter type only
needs to be a placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:50:34 +02:00
karwosts f496d842a6 Allow to favorite the manual card (#53445) 2026-08-04 12:50:33 +02:00
Petar Petrov c14d07c845 Stop the statistics graph card editor looping while typing a name (#53434) 2026-08-04 12:50:32 +02:00
Petar Petrov 8a1f2e86bd Show progress on the Zigbee backup button while the backup is created (#53422) 2026-08-04 12:49:55 +02:00
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul Bottein 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan Timson d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G. 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwosts 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram Kragten ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
Paul Bottein bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar Petrov 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar Petrov ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
Marcel van der Veldt 539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-30 23:25:15 +02:00
karwosts 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar Petrov 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
Aidan Timson 7edb9f8164 Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:25:12 +02:00
Bram Kragten ff814167c6 Bumped version to 20260729.1 2026-07-30 10:41:32 +02:00
Paul Bottein d08d8dde64 Show integration logo and name in the replace device dialog (#53384) 2026-07-30 10:41:12 +02:00
Bram Kragten 5da441ceed Strip empty strings from http config (#53377) 2026-07-30 10:41:11 +02:00
Paul Bottein 22e646a68a Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:41:11 +02:00
Maarten Lakerveld d0ab20479f Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 10:41:10 +02:00
178 changed files with 2414 additions and 6154 deletions
@@ -7,8 +7,6 @@ description: Home Assistant frontend component patterns. Use when implementing o
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
Cross-load `ha-frontend-events` when component work includes event listener typing, custom event dispatch, or event-map declarations.
## Dialogs
Open dialogs through the fire-event pattern:
-105
View File
@@ -1,105 +0,0 @@
---
name: ha-frontend-events
description: Home Assistant frontend event patterns. Use when typing event handlers, using HASSDomEvent types, dispatching with fireEvent, or declaring HASSDomEvents and event maps.
---
# HA Frontend Events
Use this skill when implementing or reviewing event listeners and custom event contracts. Cross-load `ha-frontend-components` when the work also involves dialogs, forms, alerts, shortcuts, tooltips, panels, Lovelace cards, or buttons.
## Event Handling
Use the event types from `src/common/dom/fire_event.ts` instead of plain `Event`, generic `CustomEvent`, or element casts when they express the handler contract:
- Use `HASSDomCurrentTargetEvent<T>` to read the element on which the listener was registered through `ev.currentTarget`.
- Use `HASSDomTargetEvent<T>` only to read the element that originated the event through `ev.target`.
- Use `HASSDomEvent<T>` to read a custom event payload through `ev.detail`.
- Use `ValueChangedEvent<T>` from `src/types.ts` for the standard `value-changed` event.
- Prefer an event type exported by the component being listened to, such as `HaSelectSelectEvent<T, Clearable>` or `HaDropdownSelectEvent<TValue, TData>`, over reconstructing its detail type.
- Use `ActionHandlerEvent` from `src/data/lovelace/action_handler.ts` for Lovelace tap, hold, and double-tap handlers.
Import event and element types with `import type`:
```ts
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../common/dom/fire_event";
import type { HaCheckbox } from "../components/ha-checkbox";
import type { HaEntityPicker } from "../components/entity/ha-entity-picker";
import type { HaRadioGroup } from "../components/radio/ha-radio-group";
import type { ValueChangedEvent } from "../types";
```
Type the handler so the selected property can be read directly. Do not cast `ev.currentTarget` or assign it to a single-use variable:
```ts
private _scopeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>): void {
this._scope = ev.currentTarget.value;
}
private _checkedChanged(ev: HASSDomTargetEvent<HaCheckbox>): void {
this._checked = ev.target.checked;
}
private _valueChanged(ev: ValueChangedEvent<string>): void {
this._value = ev.detail.value;
}
private _itemSelected(ev: HASSDomEvent<{ id: string }>): void {
this._selectedId = ev.detail.id;
}
```
Use intersections when a handler needs more than one facet of an event. Keep the native event type when the handler reads native fields such as `key`, modifier keys, `dataTransfer`, or focus relationships:
```ts
private _entityChanged(
ev: ValueChangedEvent<string> & HASSDomCurrentTargetEvent<HaEntityPicker>
): void {
ev.currentTarget.value = ev.detail.value;
}
private _keyDown(
ev: KeyboardEvent & HASSDomCurrentTargetEvent<HTMLInputElement>
): void {
if (ev.key === "Enter") {
this._submit(ev.currentTarget.value);
}
}
```
Dispatch Home Assistant component events with `fireEvent()` instead of constructing `Event` or `CustomEvent` directly. Register the event name and detail type by augmenting `HASSDomEvents`; use `undefined` when an event has no detail. `fireEvent()` constrains event names and supplied detail, and events bubble and cross shadow boundaries by default:
```ts
fireEvent(this, "item-selected", { id: item.id });
fireEvent(this, "refresh-requested");
```
When an event is already registered, derive handler and listener types from its registration rather than repeating the payload shape:
```ts
private _itemSelected(
ev: HASSDomEvent<HASSDomEvents["item-selected"]>
): void {
this._selectedId = ev.detail.id;
}
```
`HASSDomEvents` types `fireEvent()` calls. Augment `HTMLElementEventMap` for typed listeners on HTML elements, or `GlobalEventHandlersEventMap` when the event is handled on global event targets.
In component files, prefer placing global event declarations after the class at the bottom of the file. Preserve the existing placement when editing established files; foundational type, helper, and mixin files commonly keep declarations near the top before their consumers.
```ts
declare global {
interface HASSDomEvents {
"item-selected": { id: string };
"refresh-requested": undefined;
}
interface HTMLElementEventMap {
"item-selected": HASSDomEvent<HASSDomEvents["item-selected"]>;
}
}
```
-39
View File
@@ -1,39 +0,0 @@
---
name: ha-frontend-lit
description: Home Assistant frontend Lit conventions. Use when working with reactive properties, internal state, DOM queries, lifecycle methods, or render-derived state.
---
# HA Frontend Lit
Use this skill when implementing or reviewing Lit component state, DOM access, lifecycle methods, or rendering behavior. Cross-load `ha-frontend-types` for Home Assistant data contracts, assertions, and lifecycle parameter types.
## Reactive Fields
This project currently uses Lit's TypeScript experimental decorators with `useDefineForClassFields: false`. Match existing declarations and do not introduce standard-decorator `accessor` syntax unless the project changes decorator mode.
- Use `@property()` for public reactive API and `@state()` for private reactive state.
- Prefer inferred types for initialized reactive fields when inference preserves the intended type; annotate when widening or an external contract requires it.
## DOM Queries
Prefer Lit's `@query()` or `@queryAll()` decorators for fixed selectors in the component's render root.
- Type the decorated field with the narrowest useful DOM or component interface.
- Keep the field optional when it may be absent at the point of access, including conditional rendering or pre-render lifecycle access.
- Use a definite assignment assertion only when every call site runs after the node is guaranteed to exist.
- The optional second argument to `@query()`, as in `@query("#target", true)`, caches the first query result. Use it only when later renders cannot replace the queried node.
- Use a direct query when the selector is dynamic or the target is outside the component's render root. Before querying a child, consider whether the required value belongs in parent state or data flow.
## Render-Derived State
- Prefer render-local values for inexpensive structures used only by that render.
- Assign a render-local value once when repeated evaluation is non-trivial or a local name improves clarity.
- Keep purely presentational derivations in `render()`. Use stored state or `willUpdate()` when the value must participate in lifecycle work, reflection, CSS, or non-render consumers.
- Use `memoizeOne` for pure, argument-derived transforms when stable input identity avoids meaningful repeated work. Keep inputs explicit and limited, and do not add caching without a credible benefit over computing the value directly.
## References
- [Reactive properties](https://lit.dev/docs/components/properties/)
- [Decorators](https://lit.dev/docs/components/decorators/)
- [Shadow DOM queries](https://lit.dev/docs/components/shadow-dom/#query)
- [Reactive update cycle](https://lit.dev/docs/components/lifecycle/)
+1 -28
View File
@@ -31,33 +31,6 @@ 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.
@@ -97,4 +70,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.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
+2 -23
View File
@@ -20,7 +20,6 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn build # Full production build
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
@@ -29,26 +28,6 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Production Builds
Production builds support foreground and managed background execution:
```bash
yarn build # Full foreground build
yarn build --background # Full managed background build
yarn build --modern # Modern frontend_latest bundle only
yarn build --modern --background # Modern managed background build
yarn build --status
yarn build --logs [--follow]
yarn build --stop
```
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
@@ -62,7 +41,7 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
## Playwright E2E
@@ -80,7 +59,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
-44
View File
@@ -1,44 +0,0 @@
---
name: ha-frontend-types
description: Home Assistant frontend TypeScript conventions. Use when defining or reviewing backend data contracts, optional schemas, shared types, assertions, or Lit lifecycle types.
---
# HA Frontend Types
Use this skill for Home Assistant-specific TypeScript contracts and type choices.
## Home Assistant Data Contracts
Verify data contracts against the source that owns them, such as Home Assistant Core, Supervisor, a WebSocket handler, or an exported component type. Do not shape a type around assumptions made by its current frontend consumers.
- Match required, optional, nullable, and defaulted fields to the producer and runtime contract. Preserve optional configuration fields when omission is supported and has defined behavior.
- Use distinct request and response types when their wire shapes differ.
- When changing a shared contract, check affected consumers, tests, fixtures, and mocks.
## Reuse Home Assistant Contracts
- Reuse the canonical Home Assistant type when one exists. Define shared contract types in the data or API module that owns them.
- Reuse types exported by components and helpers rather than reconstructing their payloads. For event types, follow `ha-frontend-events`.
- When one domain contract is used across modules, define and export it from the module that owns that contract.
Prefer an existing owning contract. Introduce a frontend-specific type when the frontend shape or boundary genuinely differs.
## Assertions
Prefer accurate types and runtime narrowing. Use assertions or TypeScript suppressions at boundaries where the runtime invariant is understood but cannot be expressed cleanly; keep them narrow and explain non-obvious invariants.
## Lit Lifecycle Types
For Lit lifecycle methods that receive changed properties, use `PropertyValues<this>` when the method only needs public reactive properties:
```ts
protected willUpdate(changedProperties: PropertyValues<this>) {
// ...
}
```
Use unparameterized `PropertyValues` when the method inspects private or protected reactive properties, which are not keys of `this`. Do not add assertions solely to retain `PropertyValues<this>`.
## Enforced Baseline
Use `import type` for type-only imports. This is enforced by the repository ESLint configuration.
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: "/language:javascript-typescript"
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001
defaults:
run:
@@ -154,7 +154,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -206,7 +206,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -260,7 +260,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@ab22029681aa532bfe7de5774a9972d67bfbd2c0 # master
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
- name: Setup Node and install
uses: ./.github/actions/setup
+1 -1
View File
@@ -1 +1 @@
24.19.0
24.18.0
+944
View File
File diff suppressed because one or more lines are too long
-1000
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.18.0.cjs
yarnPath: .yarn/releases/yarn-4.17.1.cjs
-3
View File
@@ -40,9 +40,6 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
+1 -1
View File
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `yarn build`
- Production build: `script/build_frontend`
- Gallery: `cd gallery && script/develop_gallery`
## Frontend development
-311
View File
@@ -1,311 +0,0 @@
// Manage a Home Assistant frontend production build with an agent-friendly
// interface, matching build-scripts/dev-server.mjs.
//
// node build-scripts/build-manager.mjs [--modern] [mode]
//
// (no mode) Run in the foreground.
// --background Start detached, print the pid, then exit and leave it
// running.
// --status Report whether a managed build is running.
// --stop Stop a managed build.
// --logs [--follow] Print (or follow) the background build log.
//
// --modern Build only the modern frontend_latest bundle.
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LIFECYCLE_MODE_FLAGS,
acquireProcessRecord,
isProcessRecordAlive,
outputLog,
offerToStopProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
runCli,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(buildCacheDir, "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = workflowLockFile;
const usage = () => {
process.stderr.write(
"Usage: node build-scripts/build-manager.mjs [--modern] " +
"[--background | --status | --stop | --logs [--follow]]\n"
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
modes: [],
follow: false,
modern: false,
unknown: [],
};
for (const arg of argv) {
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
args.modes.push(arg);
} else if (arg === "--modern") {
args.modern = true;
} else if (arg === "--follow") {
args.follow = true;
} else {
args.unknown.push(arg);
}
}
return args;
};
const hints = () =>
" Stop: yarn build --stop\n" +
" Status: yarn build --status\n" +
" Logs: yarn build --logs\n";
const devCommand = (suite) => {
switch (suite) {
case "app-serve":
return "dev:serve";
case "demo":
return "dev:demo";
case "gallery":
return "dev:gallery";
case "e2e-app":
return "test:e2e:app:dev";
default:
return "dev";
}
};
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
const stopCommandFor = (owner) =>
owner?.kind === "build"
? "yarn build --stop"
: owner?.kind === "dev"
? `yarn ${devCommand(owner.suite)} --stop`
: undefined;
const acquireBuild = async (modern, foreground) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
foreground,
kind: "build",
modern,
starting: true,
token,
};
const result = acquireProcessRecord(lockFile, record);
if (result.acquired) {
return { token };
}
reportExisting(result.existing);
return (await offerToStopProcessRecord({
file: lockFile,
owner: result.existing,
ownerDescription: describeOutputOwner(result.existing),
stopCommand: stopCommandFor(result.existing),
}))
? acquireBuild(modern, foreground)
: { existing: result.existing };
};
const updateBuild = (token, child, processGroup) => {
const existing = readBuild();
if (existing?.token !== token) {
throw Error("Frontend build lock ownership was lost during startup.");
}
writeProcessRecord(lockFile, {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
processGroup,
starting: false,
});
};
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
const reportExisting = (existing) => {
if (existing?.kind === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the build and development workflow` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "dev") {
const command = devCommand(existing.suite);
process.stdout.write(
`Dev server (${existing.suite}) already running` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
` Stop: yarn ${command} --stop\n` +
` Status: yarn ${command} --status\n` +
` Logs: yarn ${command} --logs\n`
);
return;
}
process.stdout.write(
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
);
};
const runForeground = async (modern) => {
const lock = await acquireBuild(modern, true);
if (!lock.token) {
return 1;
}
try {
return await spawnForeground({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
} finally {
releaseBuild(lock.token);
}
};
const runBackground = async (modern) => {
const lock = await acquireBuild(modern, false);
if (!lock.token) {
return 1;
}
let child;
try {
child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateBuild(lock.token, child, true);
process.stdout.write(
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
hints()
);
return 0;
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
releaseBuild(lock.token);
throw err;
}
};
const runStatus = () => {
const existing = readBuild();
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
process.stdout.write(
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
);
} else {
if (existing?.kind === "build") {
releaseBuild(existing.token);
}
process.stdout.write("Frontend build not running.\n");
}
return 0;
};
const runStop = async () => {
let existing = readBuild();
if (existing?.kind !== "build") {
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readBuild();
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readBuild();
}
if (
!existing ||
existing.kind !== "build" ||
!isProcessRecordAlive(existing)
) {
if (existing) releaseBuild(existing.token);
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (
!(await terminateProcess({
pid: existing.pid,
processGroup: existing.processGroup,
isStopped: () => !isProcessRecordAlive(existing),
}))
) {
process.stderr.write(
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
);
return 1;
}
releaseBuild(existing.token);
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
return 0;
};
const runLogs = (follow) =>
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
const main = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.unknown.length) {
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
usage();
return 1;
}
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
process.stderr.write("Invalid combination of build arguments.\n");
usage();
return 1;
}
const handlers = {
foreground: () => runForeground(args.modern),
background: () => runBackground(args.modern),
status: runStatus,
stop: runStop,
logs: () => runLogs(args.follow),
};
return handlers[args.mode]();
};
runCli(main);
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,6 +1,5 @@
import gulp from "gulp";
import env from "../env.cjs";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./compress.js";
import "./entry-html.js";
@@ -18,7 +17,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-app"),
"clean",
gulp.parallel(
"gen-service-worker-app-dev",
@@ -38,7 +36,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app"),
"clean",
gulp.parallel(
"gen-icons-json",
@@ -54,30 +51,6 @@ gulp.task(
)
);
gulp.task(
"build-app-modern",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app-modern"),
"clean",
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
"copy-static-app",
"rspack-prod-app-modern",
gulp.parallel(
"gen-pages-app-prod-modern",
"gen-service-worker-app-prod-modern"
),
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
)
);
gulp.task(
"analyze-app",
gulp.series(
-3
View File
@@ -1,5 +1,4 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -14,7 +13,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-demo"),
"clean-demo",
"translations-enable-merge-backend",
gulp.parallel(
@@ -34,7 +32,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-demo"),
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
-3
View File
@@ -1,5 +1,4 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -13,7 +12,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
@@ -33,7 +31,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
+2 -27
View File
@@ -58,12 +58,6 @@ const getCommonTemplateVars = () => {
return {
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
hassUrl: process.env.HASS_URL || "",
// Single source for the stale-build recovery patterns, shared with the
// bundled src/util/recover-stale-build.ts and injected into the inline
// boot guard (_bootstrap_recovery.html.template).
staleBuildPatterns: fs.readJsonSync(
resolve(paths.root_dir, "src/util/stale-build-patterns.json")
),
};
};
@@ -113,9 +107,6 @@ const genPagesDevTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Dev entries are unhashed, so the stale-index recovery guard has
// nothing to key off and rebuild churn could cause spurious reloads.
useCacheRecovery: false,
latestEntryJS: entries.map(
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
),
@@ -155,15 +146,10 @@ const genPagesProdTask =
resolve(inputRoot, inputSub, `${page}.template`),
{
...commonVars,
// Recover from a stale index.html that pins deleted hashed entry
// bundles (see _bootstrap_recovery.html.template).
useCacheRecovery: true,
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
es5EntryJS: outputES5
? entries.map((entry) => es5Manifest[`${entry}.js`])
: [],
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
latestCustomPanelJS: latestManifest["custom-panel.js"],
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
es5CustomPanelJS: es5Manifest["custom-panel.js"],
}
);
minifiedHTML.push(
@@ -198,17 +184,6 @@ gulp.task(
)
);
gulp.task(
"gen-pages-app-prod-modern",
genPagesProdTask(
APP_PAGE_ENTRIES,
paths.root_dir,
paths.app_output_root,
paths.app_output_latest,
undefined
)
);
const CAST_PAGE_ENTRIES = {
"faq.html": ["launcher"],
"index.html": ["launcher"],
-3
View File
@@ -4,7 +4,6 @@ import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import { createWorkflowLockTask } from "../output-lock.mjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
@@ -165,7 +164,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
@@ -197,7 +195,6 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
+7 -26
View File
@@ -108,7 +108,7 @@ const runDevServer = async ({
}
};
const doneHandler = () => (err, stats) => {
const doneHandler = (done) => (err, stats) => {
if (err) {
log.error(err.stack || err);
if (err.details) {
@@ -122,26 +122,18 @@ const doneHandler = () => (err, stats) => {
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
if (done) {
done();
}
};
const prodBuild = (conf) =>
new Promise((resolve, reject) => {
new Promise((resolve) => {
rspack(
conf,
// Resolve promise when done. Because we pass a callback, rspack closes itself
(err, stats) => {
if (err) {
reject(err);
} else if (stats.hasErrors()) {
reject(Error(stats.toString("errors-only")));
} else {
if (stats.hasWarnings()) {
console.log(stats.toString("minimal"));
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
resolve();
}
}
doneHandler(resolve)
);
});
@@ -168,17 +160,6 @@ gulp.task("rspack-prod-app", () =>
)
);
gulp.task("rspack-prod-app-modern", () =>
prodBuild(
createAppConfig({
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
latestBuild: true,
})
)
);
gulp.task("rspack-dev-server-demo", () =>
runDevServer({
compiler: rspack(
+3 -10
View File
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
);
});
const genServiceWorker = (builds) =>
gulp.task("gen-service-worker-app-prod", () =>
Promise.all(
builds.map(async ([outPath, build]) => {
Object.entries(SW_MAP).map(async ([outPath, build]) => {
const manifest = JSON.parse(
await readFile(join(outPath, "manifest.json"), "utf-8")
);
@@ -83,12 +83,5 @@ const genServiceWorker = (builds) =>
await symlink(basename(swDest), swOld);
}
})
);
gulp.task("gen-service-worker-app-prod", () =>
genServiceWorker(Object.entries(SW_MAP))
);
gulp.task("gen-service-worker-app-prod-modern", () =>
genServiceWorker([[paths.app_output_latest, "modern"]])
)
);
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
-552
View File
@@ -1,552 +0,0 @@
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
export const LIFECYCLE_MODE_FLAGS = new Map([
["--background", "background"],
["--status", "status"],
["--stop", "stop"],
["--logs", "logs"],
]);
const AGENT_PROVIDERS = [
{
id: "opencode",
env: [
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
"OPENCODE_MODES",
],
processes: ["opencode"],
},
{
id: "claude-code",
env: ["CLAUDECODE"],
processes: ["claude"],
},
{
id: "cursor",
env: ["CURSOR_TRACE_ID"],
processes: [],
},
{
id: "github-copilot",
matchesEnv: (env) =>
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
processes: [],
},
{
id: "generic",
env: ["AGENT", "AI_AGENT"],
processes: [],
},
];
const MAX_ANCESTRY_HOPS = 24;
const readProcessParent = (pid) => {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
const commandStart = stat.indexOf("(");
if (commandStart === -1 || commandEnd === -1) {
return undefined;
}
const parentPid = Number.parseInt(
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
10
);
return Number.isFinite(parentPid)
? {
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
parentPid,
}
: undefined;
} catch {
return undefined;
}
};
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
if (env.HA_CODING_AGENT === "0") {
return undefined;
}
const envMatch = AGENT_PROVIDERS.find(
(provider) =>
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
);
if (envMatch) {
return envMatch.id;
}
if (env.HA_CODING_AGENT === "1") {
return "unknown";
}
let currentPid = pid;
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
const processInfo = readProcessParent(currentPid);
if (!processInfo) {
return undefined;
}
const processMatch = AGENT_PROVIDERS.find((provider) =>
provider.processes.some((name) => processInfo.command.includes(name))
);
if (processMatch) {
return processMatch.id;
}
if (processInfo.parentPid <= 1) {
return undefined;
}
currentPid = processInfo.parentPid;
}
return undefined;
};
const canPromptForConflict = () =>
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
const formatCommand = (command) =>
!("NO_COLOR" in process.env)
? `\u001b[1;36m${command}\u001b[0m`
: `\`${command}\``;
const confirmStopConflict = async (ownerDescription, stopCommand) => {
const readline = createInterface({
input: process.stdin,
output: process.stderr,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
process.stderr.write(
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
);
const answer = await readline.question("Stop it and continue? [y/N] ", {
signal: controller.signal,
});
return ["y", "yes"].includes(answer.trim().toLowerCase());
} catch (err) {
if (err?.name !== "AbortError") {
throw err;
}
process.stderr.write("\n");
return false;
} finally {
clearTimeout(timeout);
readline.close();
}
};
export const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
if (await predicate()) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(intervalMs);
return poll();
};
return poll();
};
export const isProcessAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
export const processStartTime = (pid) => {
if (!isProcessAlive(pid)) {
return undefined;
}
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
} catch {
try {
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
}
}
};
export const isProcessRecordAlive = ({ pid, startTime }) =>
Boolean(startTime) && processStartTime(pid) === startTime;
export const readProcessRecord = (file) => {
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
return data && Number.isInteger(data.pid) ? data : undefined;
} catch (err) {
if (err.code === "ENOENT" || err instanceof SyntaxError) {
return undefined;
}
throw err;
}
};
export const writeProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(data));
fs.renameSync(temporary, file);
} finally {
removeFileIfExists(temporary);
}
};
export const removeProcessRecord = (file) => {
removeFileIfExists(file);
};
export const acquireProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fd = fs.openSync(file, "wx");
try {
fs.writeFileSync(fd, JSON.stringify(data));
} finally {
fs.closeSync(fd);
}
return { acquired: true };
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const existing = readProcessRecord(file);
if (existing && isProcessRecordAlive(existing)) {
return { acquired: false, existing };
}
if (!existing && isRecentFile(file)) {
return { acquired: false };
}
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
const current = readProcessRecord(file);
if (
current &&
(current.token !== existing?.token || isProcessRecordAlive(current))
) {
return false;
}
removeProcessRecord(file);
return true;
});
if (!removed.acquired || !removed.value) {
return { acquired: false, existing: readProcessRecord(file) };
}
}
}
return { acquired: false, existing: readProcessRecord(file) };
};
export const releaseProcessRecord = (file, token, onRelease) => {
if (!token) {
return;
}
withExclusiveFileLockSync(`${file}.cleanup`, () => {
const existing = readProcessRecord(file);
if (!existing || existing.token === token) {
onRelease?.();
if (existing) {
removeProcessRecord(file);
}
}
});
};
const removeFileIfExists = (file) => {
try {
fs.rmSync(file);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
};
const isRecentFile = (file) => {
try {
return Date.now() - fs.statSync(file).mtimeMs < 5000;
} catch (err) {
if (err.code === "ENOENT") {
return false;
}
throw err;
}
};
export const withExclusiveFileLockSync = (file, operation) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
let fd;
try {
fd = fs.openSync(file, "wx");
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const owner = readProcessRecord(file);
if (owner && isProcessRecordAlive(owner)) {
return { acquired: false };
}
if (!owner && isRecentFile(file)) {
return { acquired: false };
}
removeFileIfExists(file);
continue;
}
try {
fs.writeFileSync(
fd,
JSON.stringify({
pid: process.pid,
startTime: processStartTime(process.pid),
})
);
return { acquired: true, value: operation() };
} finally {
try {
fs.closeSync(fd);
} finally {
removeFileIfExists(file);
}
}
}
return { acquired: false };
};
export const spawnForeground = ({
cmd,
args,
cwd,
env,
processGroup = false,
onSpawn,
}) =>
new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
env,
stdio: "inherit",
});
let settled = false;
const forwardSigint = () =>
signalProcess(child.pid, "SIGINT", processGroup);
const forwardSigterm = () =>
signalProcess(child.pid, "SIGTERM", processGroup);
const forwardSighup = () =>
signalProcess(child.pid, "SIGHUP", processGroup);
const removeSignalHandlers = () => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
process.off("SIGHUP", forwardSighup);
};
if (processGroup) {
process.on("SIGINT", forwardSigint);
process.on("SIGTERM", forwardSigterm);
process.on("SIGHUP", forwardSighup);
}
child.once("spawn", () => {
try {
onSpawn?.(child);
} catch (err) {
settled = true;
signalProcess(child.pid, "SIGTERM", processGroup);
removeSignalHandlers();
reject(err);
}
});
child.once("error", (err) => {
if (!settled) {
settled = true;
removeSignalHandlers();
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
resolve(1);
}
});
child.once("exit", (code) => {
if (!settled) {
settled = true;
removeSignalHandlers();
resolve(code ?? 1);
}
});
});
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const fd = fs.openSync(logFile, "w");
let child;
try {
child = spawn(cmd, args, {
cwd,
detached: true,
env,
stdio: ["ignore", fd, fd],
});
} finally {
fs.closeSync(fd);
}
child.once("spawn", () => {
child.unref();
resolve(child);
});
child.once("error", reject);
});
const signalProcess = (pid, signal, processGroup) => {
if (processGroup) {
try {
process.kill(-pid, signal);
return;
} catch {
// Fall back to the process itself.
}
}
try {
process.kill(pid, signal);
} catch {
// Already gone.
}
};
export const terminateProcess = async ({
pid,
isStopped,
processGroup = true,
graceMs = 10_000,
}) => {
signalProcess(pid, "SIGTERM", processGroup);
if (await waitFor(isStopped, 300, graceMs)) {
return true;
}
signalProcess(pid, "SIGKILL", processGroup);
await sleep(300);
return await isStopped();
};
const sameProcessRecord = (current, expected) =>
Boolean(expected?.token) && current?.token === expected.token;
const stopProcessRecord = async (file, owner) => {
let current = readProcessRecord(file);
if (!current) {
return true;
}
if (!sameProcessRecord(current, owner)) {
return false;
}
if (!isProcessRecordAlive(current)) {
releaseProcessRecord(file, current.token);
return true;
}
const stopped = await terminateProcess({
pid: current.pid,
processGroup: current.processGroup ?? false,
isStopped: () => {
const latest = readProcessRecord(file);
return (
!sameProcessRecord(latest, owner) ||
!latest ||
!isProcessRecordAlive(latest)
);
},
});
current = readProcessRecord(file);
if (current && !sameProcessRecord(current, owner)) {
return false;
}
if (!stopped && current) {
return false;
}
releaseProcessRecord(file, owner.token);
return true;
};
export const offerToStopProcessRecord = async ({
file,
owner,
ownerDescription,
stopCommand,
}) => {
if (
!owner ||
!stopCommand ||
!canPromptForConflict() ||
!(await confirmStopConflict(ownerDescription, stopCommand))
) {
return false;
}
return stopProcessRecord(file, owner);
};
export const terminateDetachedProcess = (child) =>
terminateProcess({
pid: child.pid,
processGroup: true,
isStopped: () => !isProcessAlive(child.pid),
});
export const outputLog = (logFile, follow, missingMessage) => {
if (!fs.existsSync(logFile)) {
process.stdout.write(missingMessage);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
let settled = false;
tail.once("error", () => {
if (!settled) {
settled = true;
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
}
});
tail.once("exit", (code) => {
if (!settled) {
settled = true;
resolve(code ?? 1);
}
});
});
};
export const runCli = (main) => {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
};
-127
View File
@@ -1,127 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
export const buildCacheDir =
process.env.HA_BUILD_CACHE_DIR ??
path.join(repoRoot, "node_modules", ".cache");
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
const signalCleanups = new Set();
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = (signal) => {
for (const cleanup of signalCleanups) {
cleanup();
}
for (const cleanupSignal of cleanupSignals) {
process.off(cleanupSignal, handleSignal);
}
process.kill(process.pid, signal);
};
const registerSignalCleanup = (cleanup) => {
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.on(signal, handleSignal);
}
}
signalCleanups.add(cleanup);
};
const unregisterSignalCleanup = (cleanup) => {
signalCleanups.delete(cleanup);
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.off(signal, handleSignal);
}
}
};
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
export const workflowLockEnv = (token) => ({
...process.env,
[WORKFLOW_LOCK_TOKEN_ENV]: token,
});
export const describeOutputOwner = (owner) => {
if (owner?.kind === "build") {
return `frontend ${owner.modern ? "modern " : ""}build`;
}
if (owner?.kind === "dev") {
return `dev server (${owner.suite ?? "app"})`;
}
return owner?.target ? `Gulp task ${owner.target}` : "another process";
};
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
let exitToken;
const cleanup = () => {
if (!exitToken) {
return;
}
releaseProcessRecord(file, exitToken);
exitToken = undefined;
process.off("exit", cleanup);
unregisterSignalCleanup(cleanup);
};
const acquire = async () => {
const inheritedToken = process.env[inheritedTokenEnv];
if (inheritedToken) {
if (readProcessRecord(file)?.token !== inheritedToken) {
throw Error(
`${label} lock ownership was lost before ${target} started.`
);
}
exitToken = inheritedToken;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
return;
}
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
kind,
target,
token,
};
const result = acquireProcessRecord(file, record);
if (!result.acquired) {
const pid = result.existing?.pid;
throw Error(
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
);
}
exitToken = token;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
};
acquire.displayName = `lock-${label}:${target}`;
return acquire;
};
export const createWorkflowLockTask = (target) =>
createLockTask({
file: workflowLockFile,
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
kind: "output",
label: "build and development workflow",
target,
});
+15 -15
View File
@@ -7,7 +7,7 @@
"name": "home-assistant-frontend",
"version": "1.0.0",
"scripts": {
"build": "node build-scripts/build-manager.mjs",
"build": "script/build_frontend",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
@@ -49,16 +49,16 @@
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.7",
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.0",
"@formatjs/intl-datetimeformat": "7.5.2",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.13",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.4.0",
"@formatjs/intl-numberformat": "9.3.14",
"@formatjs/intl-pluralrules": "6.3.13",
"@formatjs/intl-relativetimeformat": "12.3.13",
"@fullcalendar/core": "6.1.21",
@@ -107,8 +107,8 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.2.3",
"intl-messageformat": "11.2.12",
"js-yaml": "5.2.2",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -150,9 +150,9 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@playwright/test": "1.62.0",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.7",
"@rspack/core": "2.1.5",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -160,7 +160,7 @@
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
@@ -186,17 +186,17 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.8.0",
"globals": "17.7.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.3.0",
"lint-staged": "17.2.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -224,12 +224,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.8.0",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.18.0",
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.19.0"
"node": "24.18.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260729.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+2 -2
View File
@@ -8,10 +8,10 @@
":prConcurrentLimit10",
":semanticCommitsDisabled",
"group:monorepos",
"group:recommended"
"group:recommended",
"security:minimumReleaseAgeNpm"
],
"enabledManagers": ["npm", "nvm", "custom.regex"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["yarnDedupeHighest"],
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
+3 -37
View File
@@ -61,44 +61,10 @@ fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
develop_pid=$!
HASS_URL="$coreUrl" ./script/develop &
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
serve_pid=$!
stop_children() {
trap - EXIT INT TERM HUP
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
wait "$develop_pid" 2>/dev/null || true
wait "$serve_pid" 2>/dev/null || true
}
trap stop_children EXIT
trap 'stop_children; exit 130' INT
trap 'stop_children; exit 143' TERM
trap 'stop_children; exit 129' HUP
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
sleep 1
done
develop_status=
serve_status=
if ! kill -0 "$develop_pid" 2>/dev/null; then
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
fi
if ! kill -0 "$serve_pid" 2>/dev/null; then
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
fi
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
status=$develop_status
elif [ -n "$serve_status" ]; then
status=$serve_status
else
status=$develop_status
fi
exit "$status"
# keep the script running while serving
wait
+1 -6
View File
@@ -127,15 +127,10 @@ export class HaProgressButton extends LitElement {
--mdc-icon-size: 16px;
}
/* Fade the content out rather than hiding it, so the button keeps its
accessible name while the result icon covers it. */
ha-button.result::part(start),
ha-button.result::part(end),
ha-button.result::part(label),
ha-button.result::part(caret) {
opacity: 0;
}
ha-button.result::part(caret),
ha-button.result::part(spinner) {
visibility: hidden;
}
+4 -11
View File
@@ -26,10 +26,7 @@ import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { transform } from "../../common/decorators/transform";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
@@ -1219,9 +1216,7 @@ export class HaChartBase extends LitElement {
}
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
private _legendPointerDown(
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
private _legendPointerDown(ev: PointerEvent) {
// Mouse uses Ctrl/Cmd+click instead
if (ev.pointerType === "mouse") {
return;
@@ -1251,9 +1246,7 @@ export class HaChartBase extends LitElement {
}
}
private _toggleDataset(
ev: MouseEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
private _toggleDataset(ev: MouseEvent) {
ev.stopPropagation();
if (!this.chart) {
return;
@@ -1275,7 +1268,7 @@ export class HaChartBase extends LitElement {
this._handleDatasetToggle(id);
}
private _labelClick(ev: MouseEvent & HASSDomCurrentTargetEvent<HTMLElement>) {
private _labelClick(ev: MouseEvent) {
ev.stopPropagation();
if (!this.chart) {
return;
+7 -8
View File
@@ -2,10 +2,13 @@ import { customElement, property, state } from "lit/decorators";
import { LitElement, html, css } from "lit";
import type { EChartsType } from "echarts/core";
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import type {
CallbackDataParams,
ECElementEvent,
} from "echarts/types/src/util/types";
import memoizeOne from "memoize-one";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
@@ -118,15 +121,11 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartSankeyRoam = (
ev: HASSDomEvent<HASSDomEvents["chart-sankeyroam"]>
) => {
private _handleChartSankeyRoam = (ev: CustomEvent) => {
this._currentZoom = ev.detail.zoom;
};
private _handleChartClick = (
ev: HASSDomEvent<HASSDomEvents["chart-click"]>
) => {
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
if (detail.dataType !== "node") {
@@ -216,13 +216,11 @@ export class StateHistoryChartLine extends LitElement {
})}`;
};
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.id);
}
private _datasetUnhidden(
ev: HASSDomEvent<HASSDomEvents["dataset-unhidden"]>
) {
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.id);
}
@@ -231,7 +229,7 @@ export class StateHistoryChartLine extends LitElement {
chartBase.zoom(start, end, true);
}
private _handleDataZoom(ev: HASSDomEvent<HASSDomEvents["chart-zoom"]>) {
private _handleDataZoom(ev: CustomEvent) {
fireEvent(this, "chart-zoom-with-index", {
start: ev.detail.start ?? 0,
end: ev.detail.end ?? 100,
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -20,7 +21,7 @@ import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -270,7 +271,7 @@ export class StateHistoryChartTimeline extends LitElement {
chartBase.zoom(start, end, true);
}
private _handleDataZoom(ev: HASSDomEvent<HASSDomEvents["chart-zoom"]>) {
private _handleDataZoom(ev: CustomEvent) {
fireEvent(this, "chart-zoom-with-index", {
start: ev.detail.start ?? 0,
end: ev.detail.end ?? 100,
@@ -384,9 +385,7 @@ export class StateHistoryChartTimeline extends LitElement {
this._chartData = datasets;
}
private _handleChartClick(
e: HASSDomEvent<HASSDomEvents["chart-click"]>
): void {
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") {
const dataset = this._chartData[e.detail.dataIndex];
if (dataset) {
+3 -7
View File
@@ -11,10 +11,6 @@ import {
} from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import type {
HistoryResult,
LineChartUnit,
@@ -320,13 +316,13 @@ export class StateHistoryCharts extends LitElement {
}
}
private _yWidthChanged(e: HASSDomEvent<HASSDomEvents["y-width-changed"]>) {
private _yWidthChanged(e: CustomEvent<HASSDomEvents["y-width-changed"]>) {
this._childYWidths[e.detail.chartIndex] = e.detail.value;
this._maxYWidth = Math.max(...Object.values(this._childYWidths), 0);
}
private _handleTimelineSync(
e: HASSDomEvent<HASSDomEvents["chart-zoom-with-index"]>
e: CustomEvent<HASSDomEvents["chart-zoom-with-index"]>
) {
if (!this.syncCharts || this._isSyncing) {
return;
@@ -388,7 +384,7 @@ export class StateHistoryCharts extends LitElement {
}
@eventOptions({ passive: true })
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
+2 -4
View File
@@ -204,14 +204,12 @@ export class StatisticsChart extends LitElement {
`;
}
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
}
private _datasetUnhidden(
ev: HASSDomEvent<HASSDomEvents["dataset-unhidden"]>
) {
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.id);
this.requestUpdate("_hiddenStats");
}
+33 -39
View File
@@ -12,7 +12,6 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { join } from "lit/directives/join";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
@@ -484,7 +483,13 @@ export class HaDataTable extends LitElement {
: ""
}
${Object.entries(columns).map(([key, column]) => {
if (!this._isColumnVisible(key, column)) {
if (
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ??
column.defaultHidden)
: column.defaultHidden)
) {
return nothing;
}
const sorted = key === this.sortColumn;
@@ -652,7 +657,10 @@ export class HaDataTable extends LitElement {
${Object.entries(columns).map(([key, column]) => {
if (
(narrow && !column.main && !column.showNarrow) ||
!this._isColumnVisible(key, column)
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
: column.defaultHidden)
) {
return nothing;
}
@@ -684,19 +692,28 @@ export class HaDataTable extends LitElement {
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${join(
Object.entries(columns)
.filter(([key2, column2]) =>
this._isSecondaryColumnVisible(key2, column2)
)
.map(([key2, column2]) =>
column2.template
? column2.template(row)
: row[key2]
)
.filter(this._hasCellValue),
STRINGS_SEPARATOR_DOT
)}
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
}${
column2.template
? column2.template(row)
: row[key2]
}`
)}
</div>
${
column.extraTemplate
@@ -716,29 +733,6 @@ export class HaDataTable extends LitElement {
`;
};
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
if (column.hidden) {
return false;
}
if (!this.columnOrder?.includes(key)) {
return !column.defaultHidden;
}
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
}
private _isSecondaryColumnVisible(
key: string,
column: DataTableColumnData
): boolean {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
}
private _hasCellValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== "" && value !== nothing;
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;
@@ -15,10 +15,6 @@ import {
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
import { TimeZone } from "../../data/translation";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
@@ -311,7 +307,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
});
}
private _focusChanged(ev: HASSDomEvent<Date>) {
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
@@ -326,19 +322,13 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._focusDate = undefined;
}
private _handleChange(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
private _handleChange(ev: CustomEvent) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
}
private _clickDateRangeChip(
ev: HASSDomTargetEvent<
HaFilterChip & { index: number; range: [Date, Date] }
>
) {
private _clickDateRangeChip(ev: Event) {
const chip = ev.target as HaFilterChip & {
index: number;
range: [Date, Date];
@@ -346,7 +336,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
@@ -365,9 +355,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
});
}
private _handleChangeTime(
ev: ValueChangedEvent<string> & HASSDomTargetEvent<HaBaseTimeInput>
) {
private _handleChangeTime(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const time = ev.detail.value;
const target = ev.target as HaBaseTimeInput;
@@ -13,10 +13,6 @@ import {
} from "../../common/datetime/format_date";
import { valueToDate } from "../../common/datetime/value_to_date";
import { transform } from "../../common/decorators/transform";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { HomeAssistantConfig } from "../../types";
@@ -171,7 +167,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
</ha-adaptive-popover>`;
}
private _valueChanged(ev: HASSDomTargetEvent<CalendarDate>) {
private _valueChanged(ev: Event) {
const dateElement = ev.target as CalendarDate;
if (dateElement.value) {
this._updateValue(dateElement.value);
@@ -202,7 +198,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
}
private _focusChanged(ev: HASSDomEvent<Date>) {
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
@@ -7,10 +7,6 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -195,7 +191,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
}
);
private _floorMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>) {
private _floorMoved(ev: CustomEvent<HASSDomEvents["item-moved"]>) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
@@ -219,12 +215,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
fireEvent(this, "value-changed", { value: newValue });
}
private async _areaDisplayChanged(
ev: ValueChangedEvent<DisplayValue> &
HASSDomCurrentTargetEvent<
HTMLElementTagNameMap["ha-items-display-editor"] & { floorId?: string }
>
) {
private async _areaDisplayChanged(ev: ValueChangedEvent<DisplayValue>) {
ev.stopPropagation();
const value = ev.detail.value;
const currentFloorId = (ev.currentTarget as any).floorId;
+1 -2
View File
@@ -2,7 +2,6 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import type {
@@ -44,7 +43,7 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
`;
}
private _valueChanged(ev: HASSDomTargetEvent<HaCheckbox>) {
private _valueChanged(ev: Event) {
fireEvent(this, "value-changed", {
value: (ev.target as HaCheckbox).checked,
});
+1 -2
View File
@@ -2,7 +2,6 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../input/ha-input";
import type { HaInput } from "../input/ha-input";
@@ -71,7 +70,7 @@ export class HaFormFloat extends LitElement implements HaFormElement {
}
}
private _handleInput(ev: InputEvent & HASSDomTargetEvent<HaInput>) {
private _handleInput(ev: InputEvent) {
const source = ev.target as HaInput;
const rawValue = (source.value ?? "").replace(",", ".");
+2 -5
View File
@@ -2,7 +2,6 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
@@ -154,7 +153,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
);
}
private _handleCheckboxChange(ev: HASSDomTargetEvent<HaCheckbox>) {
private _handleCheckboxChange(ev: Event) {
const checked = (ev.target as HaCheckbox).checked;
let value: HaFormIntegerData | undefined;
if (checked) {
@@ -179,9 +178,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
});
}
private _valueChanged(
ev: InputEvent & HASSDomTargetEvent<HaInput | HTMLInputElement>
) {
private _valueChanged(ev: InputEvent) {
const source = ev.target as HaInput | HTMLInputElement;
const rawValue = source.value;
@@ -2,7 +2,6 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import "../ha-check-list-item";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
@@ -141,7 +140,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
}
}
private _valueChanged(ev: HASSDomTargetEvent<HaCheckbox>): void {
private _valueChanged(ev: CustomEvent): void {
const { value, checked } = ev.target as HaCheckbox;
this._handleValueChanged(value, checked);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { SelectSelector } from "../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import "../ha-selector/ha-selector-select";
import type {
HaFormElement,
@@ -78,7 +78,7 @@ export class HaFormSelect extends LitElement implements HaFormElement {
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
let value: HaFormSelectData | undefined = ev.detail.value;
+1 -2
View File
@@ -2,7 +2,6 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-icon-button";
import "../input/ha-input";
@@ -80,7 +79,7 @@ export class HaFormString extends LitElement implements HaFormElement {
}
}
protected _valueChanged(ev: HASSDomTargetEvent<HaInput>): void {
protected _valueChanged(ev: Event): void {
let value: string | undefined = (ev.target as HaInput).value;
if (this.data === value) {
return;
+5 -15
View File
@@ -3,19 +3,11 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { getHiddenFields } from "./conditions";
import type {
HaFormData,
HaFormDataContainer,
HaFormElement,
HaFormSchema,
} from "./types";
type HaFormDataChangedEvent = ValueChangedEvent<HaFormData>;
type HaFormDataContainerChangedEvent = ValueChangedEvent<HaFormDataContainer>;
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
boolean: () => import("./ha-form-boolean"),
@@ -269,18 +261,16 @@ export class HaForm extends LitElement implements HaFormElement {
}
protected addValueChangedListener(element: Element | ShadowRoot) {
element.addEventListener("value-changed", (ev: Event) => {
element.addEventListener("value-changed", (ev) => {
ev.stopPropagation();
const schema = (ev.target as HaFormElement).schema as HaFormSchema;
if (ev.target === this) return;
const changeEv = ev as
HaFormDataChangedEvent | HaFormDataContainerChangedEvent;
const newValue =
!schema.name || ("flatten" in schema && schema.flatten)
? (changeEv.detail.value as HaFormDataContainer)
: { [schema.name]: changeEv.detail.value as HaFormData };
? ev.detail.value
: { [schema.name]: ev.detail.value };
this.data = {
...this.data,
+2 -7
View File
@@ -3,7 +3,6 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import type {
Adapter,
IPv4ConfiguredAddress,
@@ -102,9 +101,7 @@ export class HaNetwork extends LitElement {
`;
}
private _handleAutoConfigureCheckboxClick(
ev: HASSDomCurrentTargetEvent<HaCheckbox>
) {
private _handleAutoConfigureCheckboxClick(ev: Event) {
const checkbox = ev.currentTarget as HaCheckbox;
if (this.networkConfig === undefined) {
return;
@@ -130,9 +127,7 @@ export class HaNetwork extends LitElement {
});
}
private _handleAdapterCheckboxClick(
ev: HASSDomCurrentTargetEvent<HaCheckbox>
) {
private _handleAdapterCheckboxClick(ev: Event) {
const checkbox = ev.currentTarget as HaCheckbox;
const adapter_name = checkbox.id;
if (this.networkConfig === undefined) {
@@ -5,7 +5,7 @@ import { getAppKey } from "../data/notify_html5";
import { showPromptDialog } from "../dialogs/generic/show-dialog-box";
import type { HaSwitch } from "./ha-switch";
import type { HomeAssistant } from "../types";
import { fireEvent, type HASSDomTargetEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-switch";
export const pushSupported =
@@ -55,7 +55,7 @@ class HaPushNotificationsToggle extends LitElement {
}
}
private _handlePushChange(ev: HASSDomTargetEvent<HaSwitch>) {
private _handlePushChange(ev: Event) {
// Somehow this is triggered on Safari on page load causing
// it to get into a loop and crash the page.
if (!pushSupported) return;
+2 -5
View File
@@ -4,7 +4,6 @@ import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import "./ha-tooltip";
export interface Segment {
@@ -121,9 +120,7 @@ class HaSegmentedBar extends LitElement {
`;
}
private _handleSegmentClick(
ev: HASSDomCurrentTargetEvent<HTMLElement>
): void {
private _handleSegmentClick(ev: Event): void {
const target = ev.currentTarget as HTMLElement;
const index = Number(target.dataset.index);
const segment = this.segments[index];
@@ -132,7 +129,7 @@ class HaSegmentedBar extends LitElement {
}
}
private _handleLegendClick(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
private _handleLegendClick(ev: Event): void {
const target = ev.currentTarget as HTMLElement;
const index = Number(target.dataset.index);
const segment = this.segments[index];
+1 -2
View File
@@ -4,7 +4,6 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-input-helper-text";
import "./ha-picker-field";
@@ -170,7 +169,7 @@ export class HaSelect extends LitElement {
: nothing;
}
private _handleSelect(ev: HaDropdownSelectEvent<string | number>) {
private _handleSelect(ev: CustomEvent<{ item: { value: string | number } }>) {
ev.stopPropagation();
const value = ev.detail.item.value;
if (value === this.value) {
+3 -3
View File
@@ -17,7 +17,7 @@ import { stringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerEvent } from "../data/lovelace/action_handler";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import {
FIXED_PANELS,
getDefaultPanelUrlPath,
@@ -634,7 +634,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
});
}
private _handleAction(ev: ActionHandlerEvent) {
private _handleAction(ev: CustomEvent<ActionHandlerDetail>) {
if (ev.detail.action !== "hold") {
return;
}
@@ -646,7 +646,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
fireEvent(this, "hass-show-notifications");
}
private _toggleSidebar(ev: ActionHandlerEvent) {
private _toggleSidebar(ev: CustomEvent) {
if (ev.detail.action !== "tap") {
return;
}
+3 -27
View File
@@ -31,8 +31,6 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -150,12 +148,7 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
return;
}
@@ -163,12 +156,7 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
return;
}
@@ -199,15 +187,12 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -268,15 +253,6 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
+1 -4
View File
@@ -2,7 +2,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import { goBack } from "../common/navigate";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-icon-button-arrow-prev";
@@ -325,9 +324,7 @@ export class HaTopAppBarFixed extends LitElement {
this._subRowResizeObserver = undefined;
}
private _subRowSlotChanged = (
ev: HASSDomCurrentTargetEvent<HTMLSlotElement>
) => {
private _subRowSlotChanged = (ev: Event) => {
const slot = ev.currentTarget as HTMLSlotElement;
this._hasSubRow = slot
.assignedNodes({ flatten: true })
+1 -2
View File
@@ -2,7 +2,6 @@ import { consume, type ContextType } from "@lit/context";
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { internationalizationContext } from "../../data/context";
import { showToast } from "../../util/toast";
@@ -112,7 +111,7 @@ export class HaInputCopy extends LitElement {
`;
}
private _focusInput(ev: HASSDomCurrentTargetEvent<HaInput>) {
private _focusInput(ev: Event) {
const inputElement = ev.currentTarget as HaInput;
inputElement.select();
}
+1 -2
View File
@@ -2,7 +2,6 @@ import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
/**
* @element ha-row-item
@@ -57,7 +56,7 @@ export class HaRowItem extends LitElement {
@state() private _hasEnd = false;
private _onSlotChange(name: "start" | "end") {
return (ev: HASSDomTargetEvent<HTMLSlotElement>) => {
return (ev: Event) => {
const slot = ev.target as HTMLSlotElement;
const hasContent = slot
.assignedNodes({ flatten: true })
-45
View File
@@ -175,8 +175,6 @@ export class HaMap extends ReactiveElement {
private _pauseAutoFit = false;
private _pendingFit?: () => void;
public connectedCallback(): void {
this._pauseAutoFit = false;
document.addEventListener("visibilitychange", this._handleVisibilityChange);
@@ -206,7 +204,6 @@ export class HaMap extends ReactiveElement {
this.Leaflet = undefined;
}
this._pendingFit = undefined;
this._loaded = false;
if (this._resizeObserver) {
@@ -350,10 +347,6 @@ export class HaMap extends ReactiveElement {
return;
}
if (this._deferIfUnsized(() => this.fitMap(options))) {
return;
}
if (
!this._mapFocusItems.length &&
!this._mapFocusZones.length &&
@@ -394,40 +387,6 @@ export class HaMap extends ReactiveElement {
}, PROGRAMMITIC_FIT_DELAY);
}
// Leaflet derives the zoom level that fits given bounds from the current
// size of the map container. When the container has not been laid out yet,
// that size is 0x0 and the computed zoom collapses to the minimum, leaving
// the map zoomed out to the world even after the container gets its size.
// Defer fitting until the resize observer reports a usable size.
private _deferIfUnsized(fit: () => void): boolean {
const size = this.leafletMap!.getSize();
if (size.x > 0 && size.y > 0) {
this._pendingFit = undefined;
return false;
}
const container = this.leafletMap!.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it.
this.leafletMap!.invalidateSize(false);
this._pendingFit = undefined;
return false;
}
this._pendingFit = fit;
return true;
}
private _runPendingFit(): void {
if (!this._pendingFit || !this.leafletMap) {
return;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
const pendingFit = this._pendingFit;
this._pendingFit = undefined;
pendingFit();
}
}
public fitBounds(
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
@@ -435,9 +394,6 @@ export class HaMap extends ReactiveElement {
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
return;
}
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
options?.pad ?? 0.5
);
@@ -817,7 +773,6 @@ export class HaMap extends ReactiveElement {
if (!this._resizeObserver) {
this._resizeObserver = new ResizeObserver(() => {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
this._runPendingFit();
});
}
this._resizeObserver.observe(this);
@@ -45,7 +45,6 @@ import {
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { panelIsReady } from "../../layouts/panel-ready";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
@@ -162,8 +161,6 @@ export class HaMediaPlayerBrowse extends LitElement {
private _resizeObserver?: ResizeObserver;
private _initialReady = false;
public connectedCallback(): void {
super.connectedCallback();
this.updateComplete.then(() => this._attachResizeObserver());
@@ -277,7 +274,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ids: navigateIds,
current: this._currentItem,
});
this._signalInitialReady();
} else {
if (!currentProm) {
currentProm = this._fetchData(
@@ -293,7 +289,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ids: navigateIds,
current: item,
});
this._signalInitialReady();
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
@@ -327,10 +322,8 @@ export class HaMediaPlayerBrowse extends LitElement {
),
code: "entity_not_found",
});
this._signalInitialReady();
} else {
this._setError(err);
this._signalInitialReady();
}
}
);
@@ -1152,21 +1145,6 @@ export class HaMediaPlayerBrowse extends LitElement {
fireEvent(this, "close-dialog");
}
private _signalInitialReady(): void {
if (this._initialReady) {
return;
}
this._initialReady = true;
const root = this.getRootNode();
panelIsReady(
root instanceof ShadowRoot &&
root.host instanceof HTMLElement &&
root.host.tagName.startsWith("HA-PANEL-")
? root.host
: this
);
}
private _setError(error: any) {
if (!this.dialog) {
this._error = error;
@@ -1231,8 +1209,8 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _animateHeaderHeight() {
let start: number | undefined;
const animate = (time: number) => {
let start;
const animate = (time) => {
if (start === undefined) {
start = time;
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
interface BranchConfig {
@@ -32,7 +31,7 @@ export class HatGraphBranch extends LitElement {
private _maxHeight = 0;
private _updateBranches(ev: HASSDomTargetEvent<HTMLSlotElement>) {
private _updateBranches(ev: Event) {
let total_width = 0;
const heights: number[] = [];
const branches: BranchConfig[] = [];
+1 -1
View File
@@ -14,7 +14,7 @@ declare global {
}
interface GlobalEventHandlersEventMap {
"connection-status": HASSDomEvent<HASSDomEvents["connection-status"]>;
"connection-status": HASSDomEvent<ConnectionStatus>;
}
}
+1 -1
View File
@@ -207,7 +207,7 @@ declare global {
"hass-related-context": RelatedContextItem | undefined;
}
interface HTMLElementEventMap {
"hass-related-context": HASSDomEvent<HASSDomEvents["hass-related-context"]>;
"hass-related-context": HASSDomEvent<RelatedContextItem | undefined>;
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ declare global {
}
interface GlobalEventHandlersEventMap {
haptic: HASSDomEvent<HASSDomEvents["haptic"]>;
haptic: HASSDomEvent<HapticType>;
}
}
-1
View File
@@ -23,7 +23,6 @@ export interface LovelaceViewElement extends HTMLElement {
badges?: HuiBadge[];
sections?: HuiSection[];
isStrategy: boolean;
initialRenderComplete?: Promise<void>;
setConfig(config: LovelaceViewConfig): void;
}
-15
View File
@@ -314,11 +314,6 @@ export interface ZWaveJSSetConfigParamResult {
error?: string;
}
export interface ZwaveJSNodeConfigParameterUpdate {
id: string;
value: number | null;
}
export interface ZWaveJSDataCollectionStatus {
enabled: boolean;
opted_in: boolean;
@@ -737,16 +732,6 @@ export const fetchZwaveNodeConfigParameters = (
device_id,
});
export const subscribeZwaveNodeConfigParameterUpdates = (
hass: HomeAssistant,
device_id: string,
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(callback, {
type: "zwave_js/subscribe_config_parameter_updates",
device_id,
});
export const setZwaveNodeConfigParameter = (
hass: HomeAssistant,
device_id: string,
@@ -2,7 +2,6 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-formfield";
@@ -162,18 +161,18 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
`;
}
private _disableNewEntitiesChanged(ev: HASSDomTargetEvent<HaSwitch>): void {
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !ev.target.checked;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: HASSDomTargetEvent<HaSwitch>): void {
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !ev.target.checked;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
@@ -70,10 +70,8 @@ declare global {
}
// for add event listener
interface HTMLElementEventMap {
"flow-update": HASSDomEvent<HASSDomEvents["flow-update"]>;
"flow-step-footer-state-changed": HASSDomEvent<
HASSDomEvents["flow-step-footer-state-changed"]
>;
"flow-update": HASSDomEvent<FlowUpdateEvent>;
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
}
}
@@ -743,7 +741,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
};
private _handleFooterStateChanged = (
ev: HASSDomEvent<HASSDomEvents["flow-step-footer-state-changed"]>
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
) => {
if (ev.detail.loading !== undefined) {
this._formStepLoading = ev.detail.loading;
+2 -2
View File
@@ -5,7 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
@@ -153,7 +153,7 @@ class StepFlowForm extends LitElement {
`;
}
private _setError(ev: HASSDomEvent<DataEntryFlowStepForm["errors"]>) {
private _setError(ev: CustomEvent) {
this._previewErrors = ev.detail;
}
+2 -27
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -12,13 +12,11 @@ import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
interface StackEntry {
params: FormDialogParams;
data: FormDialogData;
nestedField?: string;
error?: Record<string, string>;
}
@customElement("dialog-form")
@@ -38,15 +36,10 @@ export class DialogForm
@state() private _stack: StackEntry[] = [];
@state() private _error?: Record<string, string>;
@query("ha-form") private _form?: HaForm;
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
this._open = true;
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
}
@@ -66,17 +59,11 @@ export class DialogForm
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
this._stack = [
...this._stack,
{
params: this._params!,
data: this._data,
nestedField: origin?.name,
error: this._error,
},
{ params: this._params!, data: this._data, nestedField: origin?.name },
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._error = undefined;
this._initDirtyTracking({ type: "deep" }, this._data);
};
@@ -88,7 +75,6 @@ export class DialogForm
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._data = prev.data;
this._error = prev.error;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
}
@@ -102,18 +88,10 @@ export class DialogForm
this._params = undefined;
this._data = {};
this._open = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _submit(): void {
if (this._form && !this._form.reportValidity()) {
this._error = {
base: this.hass!.localize("ui.components.form.validation_failed"),
};
return;
}
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
@@ -141,7 +119,6 @@ export class DialogForm
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._error = undefined;
this._updateDirtyState(this._data);
}
@@ -159,7 +136,6 @@ export class DialogForm
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._error = undefined;
this._updateDirtyState(this._data);
}
@@ -182,7 +158,6 @@ export class DialogForm
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
.error=${this._error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
@@ -2,11 +2,9 @@ import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/ha-camera-stream";
import type { CameraEntity } from "../../../data/camera";
import { apiContext } from "../../../data/context";
@@ -68,10 +66,8 @@ class MoreInfoCamera extends LitElement {
`;
}
private async _downloadSnapshot(
ev: HASSDomCurrentTargetEvent<HaProgressButton>
) {
const button = ev.currentTarget;
private async _downloadSnapshot(ev: CustomEvent) {
const button = ev.currentTarget as any;
this._waiting = true;
try {
@@ -3,10 +3,8 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-button";
import type { HaButton } from "../../../components/ha-button";
import { apiContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistantApi } from "../../../types";
@@ -69,10 +67,8 @@ class MoreInfoCounter extends LitElement {
`;
}
private _handleActionClick(
e: MouseEvent & HASSDomCurrentTargetEvent<HaButton & { action: string }>
): void {
const action = e.currentTarget.action;
private _handleActionClick(e: MouseEvent): void {
const action = (e.currentTarget as any).action;
this._api.callService("counter", action, {
entity_id: this.stateObj!.entity_id,
});
@@ -17,7 +17,6 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { stateActive } from "../../../common/entity/state_active";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
@@ -873,14 +872,12 @@ class MoreInfoMediaPlayer extends LitElement {
});
}
private async _handleMediaSeekChanged(
e: HASSDomTargetEvent<HaSlider>
): Promise<void> {
private async _handleMediaSeekChanged(e: Event): Promise<void> {
if (!this.stateObj) {
return;
}
const newValue = e.target.value;
const newValue = (e.target as any).value;
this._api.callService("media_player", "media_seek", {
entity_id: this.stateObj.entity_id,
seek_position: newValue,
@@ -19,7 +19,7 @@ import {
hasRequiredScriptFields,
requiredScriptFieldsFilled,
} from "../../../data/script";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
@customElement("more-info-script")
@@ -209,9 +209,7 @@ class MoreInfoScript extends LitElement {
});
}
private _scriptDataChanged(
ev: ValueChangedEvent<Record<string, unknown>>
): void {
private _scriptDataChanged(ev: CustomEvent): void {
this._scriptData = { ...this._scriptData, ...ev.detail.value };
}
@@ -3,10 +3,8 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-button";
import type { HaButton } from "../../../components/ha-button";
import "../../../components/ha-duration-input";
import type { HaDurationData } from "../../../components/ha-duration-input";
import { apiContext } from "../../../data/context";
@@ -139,10 +137,8 @@ class MoreInfoTimer extends LitElement {
});
}
private _handleActionClick(
e: MouseEvent & HASSDomCurrentTargetEvent<HaButton & { action: string }>
): void {
const action = e.currentTarget.action;
private _handleActionClick(e: MouseEvent): void {
const action = (e.currentTarget as any).action;
this._api.callService("timer", action, {
entity_id: this.stateObj!.entity_id,
});
+4 -10
View File
@@ -10,7 +10,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import "../../components/animation/ha-fade-in";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-alert";
@@ -335,18 +334,13 @@ class DialogRestart extends LitElement {
}
};
private async _handleAction(
ev: HASSDomCurrentTargetEvent<
HaListItemButton & {
action: "restart" | "reboot" | "shutdown" | "restart-safe-mode";
}
>
) {
private async _handleAction(ev: Event) {
if (this._loadingBackupInfo) {
return;
}
this._loadingBackupInfo = true;
const action = ev.currentTarget.action;
const action = (ev.currentTarget as HaListItemButton & { action: string })
.action as "restart" | "reboot" | "shutdown" | "restart-safe-mode";
const backupState = await this._loadBackupState();
@@ -375,7 +369,7 @@ class DialogRestart extends LitElement {
this._dialogOpen = false;
let actionFunc: () => Promise<void>;
let actionFunc;
if (["restart", "restart-safe-mode"].includes(action)) {
const serviceData =
@@ -3,7 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatLanguageCode } from "../../common/language/format_language";
import "../../components/chips/ha-assist-chip";
@@ -18,7 +18,7 @@ import { getLanguageScores } from "../../data/conversation";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import type { VoiceAssistantSetupDialogParams } from "./show-voice-assistant-setup-dialog";
import "./voice-assistant-setup-step-area";
import "./voice-assistant-setup-step-change-wake-word";
@@ -337,7 +337,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._language = ev.detail.item.value;
}
private _languageChanged(ev: ValueChangedEvent<string | undefined>) {
private _languageChanged(ev: CustomEvent) {
if (!ev.detail.value) {
return;
}
@@ -351,7 +351,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!;
}
private async _goToNextStep(ev?: HASSDomEvent<HASSDomEvents["next-step"]>) {
private async _goToNextStep(ev?: CustomEvent) {
if (ev?.detail?.updateConfig) {
await this._fetchAssistConfiguration();
}
@@ -1,7 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
@@ -51,14 +50,14 @@ export class HaVoiceAssistantSetupStepChangeWakeWord extends LitElement {
</ha-list-base>`;
}
private async _wakeWordPicked(
ev: HASSDomCurrentTargetEvent<HaListItemButton & { value: string }>
) {
private async _wakeWordPicked(ev: Event) {
if (!this.assistEntityId) {
return;
}
const wakeWordId = ev.currentTarget.value;
const wakeWordId = (
ev.currentTarget as HaListItemButton & { value: string }
).value;
await setWakeWords(this.hass, this.assistEntityId, [wakeWordId]);
this._nextStep();
@@ -19,7 +19,7 @@ import type { LanguageScore, LanguageScores } from "../../data/conversation";
import { getLanguageScores, listAgents } from "../../data/conversation";
import { listSTTEngines } from "../../data/stt";
import { listTTSEngines, listTTSVoices } from "../../data/tts";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { AssistantSetupStyles } from "./styles";
import { STEP } from "./voice-assistant-setup-dialog";
@@ -265,8 +265,8 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
}
private async _createCloudPipeline(useLanguage: boolean): Promise<boolean> {
let cloudTtsEntityId: string | undefined;
let cloudSttEntityId: string | undefined;
let cloudTtsEntityId;
let cloudSttEntityId;
for (const entity of Object.values(this.hass.entities)) {
if (entity.platform === "cloud") {
const domain = computeDomain(entity.entity_id);
@@ -327,7 +327,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
const ttsVoices = await listTTSVoices(
this.hass,
cloudTtsEntityId!,
cloudTtsEntityId,
ttsEngine.supported_languages[0]
);
@@ -357,9 +357,9 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
language: (this.language || this.hass.config.language).split("-")[0],
conversation_engine: "conversation.home_assistant",
conversation_language: agent.supported_languages[0],
stt_engine: cloudSttEntityId!,
stt_engine: cloudSttEntityId,
stt_language: sttEngine.supported_languages[0],
tts_engine: cloudTtsEntityId!,
tts_engine: cloudTtsEntityId,
tts_language: ttsEngine.supported_languages[0],
tts_voice: ttsVoices.voices![0].voice_id,
wake_word_entity: null,
@@ -380,9 +380,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
}
}
private _valueChanged(
ev: ValueChangedEvent<(typeof OPTIONS)[number] | undefined>
) {
private _valueChanged(ev: CustomEvent) {
this._value = ev.detail.value;
}
@@ -412,7 +410,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
fireEvent(this, "next-step", { step: STEP.LOCAL, option: this._value });
}
private _languageChanged(ev: ValueChangedEvent<string | undefined>) {
private _languageChanged(ev: CustomEvent) {
if (!ev.detail.value) {
return;
}
+1 -12
View File
@@ -37,26 +37,15 @@ declare global {
}
const clearUrlParams = () => {
const searchParams = new URLSearchParams(location.search);
let changed = false;
// Clear auth data from url if we have been able to establish a connection
if (location.search.includes("auth_callback=1")) {
const searchParams = new URLSearchParams(location.search);
// https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/auth.ts
// Remove all data from QueryCallbackData type
searchParams.delete("auth_callback");
searchParams.delete("code");
searchParams.delete("state");
searchParams.delete("storeToken");
changed = true;
}
// Remove the cache-busting param added by the stale-index recovery guard in
// index.html once we have booted successfully, so it doesn't linger in the
// URL (and stops acting as the guard's one-shot loop marker).
if (searchParams.has("ha_cache_bust")) {
searchParams.delete("ha_cache_bust");
changed = true;
}
if (changed) {
const search = searchParams.toString();
history.replaceState(
null,
+1 -9
View File
@@ -121,14 +121,6 @@ declare global {
}
interface GlobalEventHandlersEventMap {
"improv-discovered-device": HASSDomEvent<
HASSDomEvents["improv-discovered-device"]
>;
"improv-device-setup-done": HASSDomEvent<
HASSDomEvents["improv-device-setup-done"]
>;
"matter-commission-finish": HASSDomEvent<
HASSDomEvents["matter-commission-finish"]
>;
"matter-commission-finish": HASSDomEvent<MatterCommissionFinish>;
}
}
-130
View File
@@ -1,130 +0,0 @@
<script>
/*
* Boot-time recovery from a stale index.html.
*
* index.html pins the content-hashed entry bundles (core.<hash>.js /
* app.<hash>.js). After a frontend release those files are deleted from
* disk, so a cached (stale) index.html imports URLs that now 404 and the
* app never boots - the launch screen stays up forever. No bundled JS can
* recover this, because the bundle itself failed to load, so this guard has
* to live inline in the HTML document.
*
* On a failed entry load we navigate once to the same URL with a
* cache-busting query param (and, on https, after dropping the service
* worker + its caches) so the browser fetches a fresh index.html that points
* at the current hashes. The param doubles as a one-shot loop guard: if the
* freshly fetched index still fails we stop and show a message instead of
* reloading forever. core.ts strips the param again after a successful boot.
*/
(function () {
var BUST_PARAM = "ha_cache_bust";
// Only production entry bundles carry a content hash. Requiring the hash
// keeps the guard inert in development (unhashed core.js / app.js) and
// scoped to the entry chunks whose 404 is fatal to boot - not translations
// or lazily loaded panels, which fail non-fatally and are handled elsewhere.
// Injected from stale-build-patterns.json (the single source shared with
// the bundled util/recover-stale-build.ts) so the two never drift.
var HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
var MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
function booted() {
// The launch screen is only removed once the app has taken over the
// page. If it is gone, any later chunk failure is post-boot (a lazy
// panel/dialog) and is not the stale-index boot failure this guard
// targets, so we leave it alone.
return !document.getElementById("ha-launch-screen");
}
function showFatal() {
var box = document.getElementById("ha-launch-screen-info-box");
if (box) {
box.textContent =
"Could not load Home Assistant. Please refresh the page, and clear your browser cache if the problem persists.";
}
}
function recover() {
if (location.search.indexOf(BUST_PARAM + "=") !== -1) {
// We already reloaded once with a fresh index and it still failed;
// stop to avoid a reload loop.
showFatal();
return;
}
var target =
location.pathname +
location.search +
(location.search ? "&" : "?") +
BUST_PARAM +
"=" +
Date.now() +
location.hash;
// On https the service worker "/" route ignores the query string
// (ignoreSearch), so a bust param alone would still be answered with the
// stale cached index. Drop the worker and its caches first so the reload
// is served fresh from the network.
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
var go = function () {
location.replace(target);
};
Promise.all([
navigator.serviceWorker
.getRegistrations()
.then(function (regs) {
return Promise.all(
regs.map(function (r) {
return r.unregister();
})
);
})
.catch(function () {}),
self.caches
? caches
.keys()
.then(function (keys) {
return Promise.all(
keys.map(function (k) {
return caches.delete(k);
})
);
})
.catch(function () {})
: null,
]).then(go, go);
} else {
location.replace(target);
}
}
function maybeRecover(url, message) {
if (booted()) {
return;
}
if (
(url && HASHED_ENTRY.test(url)) ||
(message && (HASHED_ENTRY.test(message) || MODULE_ERROR.test(message)))
) {
recover();
}
}
// Resource-load errors (<script>, modulepreload <link>) do not bubble, so
// they are only observable in the capture phase at the window.
window.addEventListener(
"error",
function (ev) {
var el = ev.target;
if (el && (el.tagName === "SCRIPT" || el.tagName === "LINK")) {
maybeRecover(el.href || el.src, null);
}
},
true
);
// A failed dynamic import() rejects; the inline entry imports do not catch
// it, so the rejection surfaces here.
window.addEventListener("unhandledrejection", function (ev) {
var reason = ev && ev.reason;
maybeRecover(null, reason && (reason.message || String(reason)));
});
})();
</script>
-1
View File
@@ -2,7 +2,6 @@
<html>
<head>
<title>Home Assistant</title>
<% if (useCacheRecovery) { %><%= renderTemplate("_bootstrap_recovery.html.template") %><% } %>
<%= renderTemplate("_header.html.template") %>
<link rel="mask-icon" href="/static/icons/mask-icon.svg" color="#18bcf2" />
<link
+1 -4
View File
@@ -1,7 +1,6 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/animation/ha-fade-in";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
import type { HomeAssistant } from "../types";
@@ -37,9 +36,7 @@ class HassLoadingScreen extends LitElement {
private _renderContent(): TemplateResult {
return html`
<div class="content">
<ha-fade-in .delay=${500}>
<ha-spinner></ha-spinner>
</ha-fade-in>
<ha-spinner></ha-spinner>
${
this.message
? html`<div id="loading-text">${this.message}</div>`
+1 -2
View File
@@ -3,7 +3,6 @@ import { css, html, LitElement } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
@@ -75,7 +74,7 @@ class HassSubpage extends LitElement {
}
@eventOptions({ passive: true })
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
+2 -4
View File
@@ -17,7 +17,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { canShowPage } from "../common/config/can_show_page";
import { fireEvent, type HASSDomTargetEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/chips/ha-assist-chip";
import "../components/data-table/ha-data-table";
@@ -740,9 +740,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
this._dataTable.clearSelection();
};
private _handleSearchChange(
ev: InputEvent & HASSDomTargetEvent<HaInputSearch>
) {
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
if (this.filter === target.value) {
return;
+1 -2
View File
@@ -12,7 +12,6 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -233,7 +232,7 @@ export class HassTabsSubpage extends LitElement {
}
@eventOptions({ passive: true })
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
-57
View File
@@ -1,5 +1,3 @@
import { ContextProvider, createContext } from "@lit/context";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { ReactiveElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
@@ -17,61 +15,6 @@ export const panelIsReady = async (element: HTMLElement) => {
fireEvent(element, "hass-panel-ready");
};
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
export const childPanelReadyContext =
createContext<RegisterChildPanelReady>("child-panel-ready");
export class ChildPanelReady implements ReactiveController {
private _promises: Promise<void>[] = [];
private _host: ReactiveControllerHost & HTMLElement;
private _resolveReady?: () => void;
private _completing = false;
public ready = new Promise<void>((resolve) => {
this._resolveReady = resolve;
});
public constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
host.addController(this);
new ContextProvider(host, {
context: childPanelReadyContext,
initialValue: (ready) => this._promises.push(ready),
});
}
public hostUpdated() {
if (this._completing) {
return;
}
this._completing = true;
this._host.removeController(this);
void this._complete();
}
private async _complete() {
// Children created/updated during this render register after the host's
// update commits. Wait for that before snapshotting readiness promises.
await this._host.updateComplete;
await this._waitForPromises();
this._resolveReady?.();
await panelIsReady(this._host);
}
private _waitForPromises(): Promise<void> {
const count = this._promises.length;
return Promise.allSettled(this._promises).then(() =>
this._promises.length > count ? this._waitForPromises() : undefined
);
}
}
export class PanelReady {
public ready?: Promise<void>;
+28 -67
View File
@@ -19,66 +19,29 @@ import { HassRouterPage } from "./hass-router-page";
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
const PANEL_READY_TIMEOUT = 2000;
const DASHBOARD_READY_TIMEOUT = 5000;
const COMPONENTS = {
app: { load: () => import("../panels/app/ha-panel-app") },
energy: {
load: () => import("../panels/energy/ha-panel-energy"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
calendar: {
load: () => import("../panels/calendar/ha-panel-calendar"),
waitForReady: true,
},
config: { load: () => import("../panels/config/ha-panel-config") },
custom: { load: () => import("../panels/custom/ha-panel-custom") },
lovelace: {
load: () => import("../panels/lovelace/ha-panel-lovelace"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
history: { load: () => import("../panels/history/ha-panel-history") },
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
map: { load: () => import("../panels/map/ha-panel-map") },
my: { load: () => import("../panels/my/ha-panel-my") },
profile: { load: () => import("../panels/profile/ha-panel-profile") },
todo: { load: () => import("../panels/todo/ha-panel-todo") },
"media-browser": {
load: () => import("../panels/media-browser/ha-panel-media-browser"),
waitForReady: true,
},
light: {
load: () => import("../panels/light/ha-panel-light"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
security: {
load: () => import("../panels/security/ha-panel-security"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
climate: {
load: () => import("../panels/climate/ha-panel-climate"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
maintenance: {
load: () => import("../panels/maintenance/ha-panel-maintenance"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
home: {
load: () => import("../panels/home/ha-panel-home"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
} satisfies Record<
string,
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
>;
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
calendar: () => import("../panels/calendar/ha-panel-calendar"),
config: () => import("../panels/config/ha-panel-config"),
custom: () => import("../panels/custom/ha-panel-custom"),
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
history: () => import("../panels/history/ha-panel-history"),
iframe: () => import("../panels/iframe/ha-panel-iframe"),
logbook: () => import("../panels/logbook/ha-panel-logbook"),
map: () => import("../panels/map/ha-panel-map"),
my: () => import("../panels/my/ha-panel-my"),
profile: () => import("../panels/profile/ha-panel-profile"),
todo: () => import("../panels/todo/ha-panel-todo"),
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
@customElement("partial-panel-resolver")
class PartialPanelResolver extends HassRouterPage {
@@ -163,12 +126,13 @@ class PartialPanelResolver extends HassRouterPage {
private _getRoutes(panels: Panels): RouterOptions {
const routes: RouterOptions["routes"] = {};
Object.values(panels).forEach((panel) => {
const component = COMPONENTS[panel.component_name];
const data: RouteOptions = {
tag: `ha-panel-${panel.component_name}`,
cache: CACHE_URL_PATHS.includes(panel.url_path),
...(component ?? {}),
};
if (panel.component_name in COMPONENTS) {
data.load = COMPONENTS[panel.component_name];
}
routes[panel.url_path] = data;
});
@@ -257,12 +221,9 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
const component =
COMPONENTS[this.hass.panels[this._currentPage].component_name];
await promiseTimeout(
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
this.pageRendered
).catch(() => undefined);
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
() => undefined
);
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
+82 -216
View File
@@ -1,20 +1,7 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -23,7 +10,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for updating or closing a specific toast without flickering.
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message:
| string
@@ -47,150 +34,105 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _notifications: Notification[] = [];
@state() private _parameters?: ShowToastParams;
@query(".stack") private _stack?: HTMLDivElement;
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
private _showDialogId = 0;
public async showDialog(parameters: ShowToastParams) {
if (parameters.duration === 0) {
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (parameters.duration === 0) {
this._parameters = undefined;
return;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
this._parameters = parameters;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
await this.updateComplete;
this._showStack();
this._getToast(key)?.show();
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
}
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
if (ev.detail.reason === "dismiss") {
this._getNotification(key)?.parameters.dismiss?.();
this._parameters?.dismiss?.();
}
this._removeNotification(key);
this._parameters = undefined;
}
protected render() {
if (!this._notifications.length) {
if (!this._parameters) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -199,7 +141,6 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -214,94 +155,19 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
}
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
}
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
private _dismissClicked() {
this._toast?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || this._stack?.matches(":popover-open")) {
return;
}
this._stack?.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
+1 -1
View File
@@ -36,7 +36,7 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState && this.isConnected) {
if (this.isDirtyState) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
+6 -10
View File
@@ -37,7 +37,7 @@ import { subscribeUser } from "../data/ws-user";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { HassElement } from "../state/hass-element";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import { registerServiceWorker } from "../util/register-service-worker";
import "../components/progress/ha-progress-bar";
@@ -80,8 +80,8 @@ declare global {
}
interface GlobalEventHandlersEventMap {
"onboarding-step": HASSDomEvent<HASSDomEvents["onboarding-step"]>;
"onboarding-progress": HASSDomEvent<HASSDomEvents["onboarding-progress"]>;
"onboarding-step": HASSDomEvent<OnboardingEvent>;
"onboarding-progress": HASSDomEvent<OnboardingProgressEvent>;
}
}
@@ -332,9 +332,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
}
private _handleProgress(
ev: HASSDomEvent<HASSDomEvents["onboarding-progress"]>
) {
private _handleProgress(ev: HASSDomEvent<OnboardingProgressEvent>) {
const stepSize = 100 / this._steps!.length;
if (ev.detail.increase) {
this._progress += ev.detail.increase * stepSize;
@@ -347,9 +345,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
}
private async _handleStepDone(
ev: HASSDomEvent<HASSDomEvents["onboarding-step"]>
) {
private async _handleStepDone(ev: HASSDomEvent<OnboardingEvent>) {
const stepResult = ev.detail;
this._steps = this._steps!.map((step) =>
step.step === stepResult.type ? { ...step, done: true } : step
@@ -483,7 +479,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
});
}
private _languageChanged(ev: ValueChangedEvent<string>) {
private _languageChanged(ev: CustomEvent) {
const language = ev.detail.value;
this.language = language;
if (this.hass) {
+2 -4
View File
@@ -2,7 +2,7 @@ import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-analytics";
import "../components/ha-button";
@@ -65,9 +65,7 @@ class OnboardingAnalytics extends LitElement {
});
}
private _preferencesChanged(
event: HASSDomEvent<HASSDomEvents["analytics-preferences-changed"]>
): void {
private _preferencesChanged(event: CustomEvent): void {
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: event.detail.preferences,
+2 -4
View File
@@ -19,7 +19,7 @@ import type { ConfigUpdateValues } from "../data/core";
import { saveCoreConfig } from "../data/core";
import { countryCurrency } from "../data/currency";
import { onboardCoreConfigStep } from "../data/onboarding";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import "./onboarding-location";
@@ -132,9 +132,7 @@ class OnboardingCoreConfig extends LitElement {
});
}
private _handleFormChanged(
ev: ValueChangedEvent<{ country?: string; time_zone?: string }>
) {
private _handleFormChanged(ev: CustomEvent) {
const value = ev.detail.value as { country?: string; time_zone?: string };
this._country = value.country || undefined;
if (value.time_zone) {
+1 -4
View File
@@ -2,7 +2,6 @@ import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../common/decorators/storage";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { removeSearchParam } from "../common/url/search-params";
@@ -281,9 +280,7 @@ class OnboardingRestoreBackup extends LitElement {
setTimeout(() => this._loadBackupInfo(), delay);
}
private async _backupUploaded(
ev: HASSDomEvent<HASSDomEvents["backup-uploaded"]>
) {
private async _backupUploaded(ev: CustomEvent) {
this._backupId = ev.detail.backupId;
await this._loadBackupInfo();
}
@@ -2,10 +2,6 @@ import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { formatDateTimeWithBrowserDefaults } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../components/buttons/ha-progress-button";
@@ -13,7 +9,6 @@ import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import "../../components/item/ha-row-item";
import {
getPreferredAgentForDownload,
@@ -24,7 +19,6 @@ import { restoreOnboardingBackup } from "../../data/backup_onboarding";
import "../../panels/config/backup/components/ha-backup-data-picker";
import "../../panels/config/backup/components/ha-backup-formfield-label";
import { onBoardingStyles } from "../styles";
import type { ValueChangedEvent } from "../../types";
@customElement("onboarding-restore-backup-restore")
class OnboardingRestoreBackupRestore extends LitElement {
@@ -253,20 +247,16 @@ class OnboardingRestoreBackupRestore extends LitElement {
fireEvent(this, "sign-out");
}
private _selectedBackupChanged(ev: ValueChangedEvent<BackupData>) {
private _selectedBackupChanged(ev: CustomEvent) {
ev.stopPropagation();
this._selectedData = ev.detail.value;
}
private _encryptionKeyChanged(
ev: HASSDomTargetEvent<Omit<HaInput, "value"> & { value: string }>
): void {
private _encryptionKeyChanged(ev): void {
this._encryptionKey = ev.target.value;
}
private async _startRestore(
ev: HASSDomCurrentTargetEvent<HaProgressButton>
): Promise<void> {
private async _startRestore(ev: CustomEvent): Promise<void> {
const agentId = Object.keys(this.backup.agents)[0];
const backupProtected = this.backup.agents[agentId].protected;
@@ -280,7 +270,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
}
this._loading = true;
const button = ev.currentTarget;
const button = ev.currentTarget as HaProgressButton;
this._error = undefined;
this._encryptionKeyWrong = false;
+48 -64
View File
@@ -1,23 +1,14 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog-footer";
import "../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../components/radio/ha-radio-group";
import "../../components/radio/ha-radio-option";
import "../../components/ha-svg-icon";
import "../../components/ha-switch";
import "../../components/ha-dialog";
import { RecurrenceRange } from "../../data/calendar";
import type { HomeAssistant } from "../../types";
import type { ConfirmEventDialogBoxParams } from "./show-confirm-event-dialog-box";
// `RecurrenceRange.THISEVENT` is "", which the radio group treats as unselected
// on its value-attribute and form-reset paths, so keep the options on their own
// literals and map them when confirming.
type RecurrenceScope = "this" | "future";
import "../../components/ha-button";
@customElement("confirm-event-dialog-box")
class ConfirmEventDialogBox extends LitElement {
@@ -27,14 +18,11 @@ class ConfirmEventDialogBox extends LitElement {
@state() private _open = false;
@state() private _closeState?: "canceled" | "confirmed";
@state() private _scope: RecurrenceScope = "this";
@state()
private _closeState?: "canceled" | "confirmed" | "confirmedFuture";
public async showDialog(params: ConfirmEventDialogBoxParams): Promise<void> {
this._params = params;
this._scope = "this";
this._closeState = undefined;
this._open = true;
}
@@ -51,27 +39,20 @@ class ConfirmEventDialogBox extends LitElement {
return nothing;
}
const { destructive, recurring } = this._params;
return html`
<ha-dialog
.open=${this._open}
header-title=${this._params.title}
width="small"
type="alert"
aria-describedby=${recurring ? nothing : "description"}
@closed=${this._dialogClosed}
>
${
recurring
? this._renderRecurrenceRange()
: html`<p id="description">${this._params.text}</p>`
}
<div>
<p>${this._params.text}</p>
</div>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@click=${this._dismiss}
?autofocus=${!recurring && destructive}
slot="secondaryAction"
>
${this.hass.localize("ui.common.cancel")}
@@ -79,41 +60,29 @@ class ConfirmEventDialogBox extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._confirm}
?autofocus=${!recurring && !destructive}
variant=${destructive ? "danger" : "brand"}
autofocus
variant="danger"
>
${this._params.confirmText}
</ha-button>
${
this._params.confirmFutureText
? html`
<ha-button
@click=${this._confirmFuture}
slot="primaryAction"
variant="danger"
>
${this._params.confirmFutureText}
</ha-button>
`
: ""
}
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderRecurrenceRange() {
const thisEvent = this.hass.localize(
"ui.components.calendar.event.recurrence_range.this_event"
);
const thisAndFuture = this.hass.localize(
"ui.components.calendar.event.recurrence_range.this_and_future"
);
return html`
<ha-radio-group
name="recurrence_range"
.label=${this._params!.text ?? this._params!.title}
.value=${this._scope}
@change=${this._scopeChanged}
>
<ha-radio-option value="this" autofocus>${thisEvent}</ha-radio-option>
<ha-radio-option value="future">${thisAndFuture}</ha-radio-option>
</ha-radio-group>
`;
}
private _scopeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>): void {
this._scope = ev.currentTarget.value as RecurrenceScope;
}
private _dismiss(): void {
this._closeState = "canceled";
this.closeDialog();
@@ -121,11 +90,17 @@ class ConfirmEventDialogBox extends LitElement {
private _confirm(): void {
this._closeState = "confirmed";
this._params!.confirm?.(
this._scope === "future"
? RecurrenceRange.THISANDFUTURE
: RecurrenceRange.THISEVENT
);
if (this._params!.confirm) {
this._params!.confirm(RecurrenceRange.THISEVENT);
}
this.closeDialog();
}
private _confirmFuture(): void {
this._closeState = "confirmedFuture";
if (this._params!.confirm) {
this._params!.confirm(RecurrenceRange.THISANDFUTURE);
}
this.closeDialog();
}
@@ -133,7 +108,10 @@ class ConfirmEventDialogBox extends LitElement {
if (!this._params) {
return;
}
if (this._closeState !== "confirmed") {
if (
this._closeState !== "confirmed" &&
this._closeState !== "confirmedFuture"
) {
this._params.cancel?.();
}
this._params = undefined;
@@ -147,12 +125,18 @@ class ConfirmEventDialogBox extends LitElement {
pointer-events: initial !important;
cursor: initial !important;
}
a {
color: var(--primary-color);
}
p {
margin: 0;
color: var(--primary-text-color);
}
ha-radio-group::part(form-control-label) {
font-weight: var(--ha-font-weight-medium);
.no-bottom-padding {
padding-bottom: 0;
}
.secondary {
color: var(--secondary-text-color);
}
ha-dialog {
/* Place above other dialogs */
@@ -224,9 +224,18 @@ class DialogCalendarEventDetail extends LitElement {
: this.hass.localize(
"ui.components.calendar.event.confirm_delete.prompt"
),
confirmText: this.hass.localize("ui.common.delete"),
recurring: !!entry.recurrence_id,
destructive: true,
confirmText: entry.recurrence_id
? this.hass.localize(
"ui.components.calendar.event.confirm_delete.delete_this"
)
: this.hass.localize(
"ui.components.calendar.event.confirm_delete.delete"
),
confirmFutureText: entry.recurrence_id
? this.hass.localize(
"ui.components.calendar.event.confirm_delete.delete_future"
)
: undefined,
});
if (range === undefined) {
// Cancel
@@ -569,8 +569,12 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
text: this.hass.localize(
"ui.components.calendar.event.confirm_update.recurring_prompt"
),
confirmText: this.hass.localize("ui.common.update"),
recurring: true,
confirmText: this.hass.localize(
"ui.components.calendar.event.confirm_update.update_this"
),
confirmFutureText: this.hass.localize(
"ui.components.calendar.event.confirm_update.update_future"
),
});
}
if (range === undefined) {
@@ -621,9 +625,18 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
: this.hass.localize(
"ui.components.calendar.event.confirm_delete.prompt"
),
confirmText: this.hass.localize("ui.common.delete"),
recurring: !!entry.recurrence_id,
destructive: true,
confirmText: entry.recurrence_id
? this.hass.localize(
"ui.components.calendar.event.confirm_delete.delete_this"
)
: this.hass.localize(
"ui.components.calendar.event.confirm_delete.delete"
),
confirmFutureText: entry.recurrence_id
? this.hass.localize(
"ui.components.calendar.event.confirm_delete.delete_future"
)
: undefined,
});
if (range === undefined) {
// Cancel
-7
View File
@@ -35,7 +35,6 @@ import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { panelIsReady } from "../../layouts/panel-ready";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { haStyle } from "../../resources/styles";
import type { CalendarViewChanged, HomeAssistant } from "../../types";
@@ -78,8 +77,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _mql?: MediaQueryList;
private _initialReady = false;
public connectedCallback() {
super.connectedCallback();
this._mql = window.matchMedia(
@@ -106,10 +103,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
this._entityRegistry = entities;
// Refresh calendars when entity registry updates (includes color changes)
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
if (!this._initialReady) {
this._initialReady = true;
panelIsReady(this);
}
// Resubscribe events if view dates are available (handles both initial load and color updates)
if (this._start && this._end) {
this._unsubscribeAll().then(() => {

Some files were not shown because too many files have changed in this diff Show More