mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-15 16:54:03 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5da32f324 | |||
| 1cbc60fe86 | |||
| 41cba54094 | |||
| a9dc02952b | |||
| 60c181d5c8 | |||
| 86e0536de0 | |||
| c427acffe2 | |||
| 12d4b7207e | |||
| 00b297b0fe | |||
| 307bb844fe | |||
| 5291c37c88 | |||
| c2e9c5e257 | |||
| 1ce87aa5d4 | |||
| 086581d280 | |||
| 0774f352fb | |||
| 5ee8f7b4a4 | |||
| 52788d7f71 | |||
| 7406cc8cea | |||
| 2e5731ff82 | |||
| d78e9890bc | |||
| 3eaf64bc97 | |||
| 1595e3bffc | |||
| df0969a4f5 | |||
| e3c917b65d | |||
| d2ed98613b | |||
| 6185f5033f | |||
| 78887965ed | |||
| 2a5e4d5c88 | |||
| bbfe5b6be8 | |||
| 5c0ef1c653 | |||
| 7b64046a2f | |||
| e760df5a27 | |||
| 8bafdbcec6 | |||
| a20745c274 | |||
| 598e0935f6 | |||
| a9d560096a | |||
| f1e89688b9 | |||
| 42c40fccdb | |||
| b2d2eda50f | |||
| d37a07b1b4 | |||
| 0a74b515ff | |||
| e358d1478a | |||
| ac4a4dcd70 | |||
| bd70df459b | |||
| da6f712d2c | |||
| 160eaaac4f | |||
| c70498c057 | |||
| 1b9841185c | |||
| d1233489ab | |||
| 5315c5710f | |||
| 7e60fbb66e | |||
| cea86f0aa0 | |||
| 85eb2c9f6e | |||
| b401cd1500 | |||
| 598e8d4a45 | |||
| 030d777f26 | |||
| 194b7edf3a | |||
| 97ed3afe4b | |||
| 77cc3cfe32 | |||
| 1350fd3674 | |||
| 9a283aba78 | |||
| 7f04b0884b | |||
| a4c07e5fc2 | |||
| 42e0a1e4b5 | |||
| 438e2e5173 | |||
| 0548f99057 | |||
| ea75339392 | |||
| 7e244a706b | |||
| 7911aef6c0 | |||
| ed00d4ded1 | |||
| be55a2197e | |||
| a73c659840 | |||
| 207025d9a4 | |||
| e9431105b0 | |||
| 1e5560fd42 | |||
| 369e446149 | |||
| 318dac3bf1 | |||
| 8ee9c95454 | |||
| 8ea65987a7 | |||
| ada5be6af5 | |||
| 5bcb6628b0 | |||
| 24fd2241df | |||
| db0813904c | |||
| b30668995d | |||
| 9ba003f86a | |||
| 0536e2cd2a |
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: ha-frontend-components
|
||||
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
|
||||
---
|
||||
|
||||
# HA Frontend Components
|
||||
|
||||
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
|
||||
|
||||
## Dialogs
|
||||
|
||||
Open dialogs through the fire-event pattern:
|
||||
|
||||
```ts
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
- Fire `dialog-closed` in the close handler.
|
||||
- Use `header-title` and `header-subtitle` for simple header text.
|
||||
- Use slots when standard header attributes are not enough.
|
||||
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
|
||||
- Add `autofocus` to the first focusable element, such as `<ha-form autofocus>`, and forward it internally if needed.
|
||||
|
||||
Use standard dialog widths: `small`, `medium`, `large`, or `full`. Avoid custom dialog sizing unless there is a clear product need.
|
||||
|
||||
## Buttons
|
||||
|
||||
`ha-button` wraps the Web Awesome button in `src/components/ha-button.ts`.
|
||||
|
||||
Axes:
|
||||
|
||||
- `variant`: `brand`, `neutral`, `danger`, `warning`, `success`.
|
||||
- `appearance`: `accent`, `filled`, `outlined`, `plain`.
|
||||
- `size`: `xs`, `s`, `m`, `l`, `xl`.
|
||||
|
||||
Common usage:
|
||||
|
||||
- Use `appearance="filled"` for primary emphasis when needed.
|
||||
- Use `appearance="plain"` for cancel and dismiss actions.
|
||||
- Use `variant="danger"` for destructive actions.
|
||||
- Place primary actions in `slot="primaryAction"` and secondary actions in `slot="secondaryAction"`.
|
||||
|
||||
## Forms
|
||||
|
||||
`ha-form` is schema-driven with `HaFormSchema[]` and supports common selectors for entities, devices, areas, targets, numbers, booleans, time, actions, text, objects, selects, icons, media, and location.
|
||||
|
||||
Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, validation, and helper text.
|
||||
|
||||
```ts
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
Use `ha-alert` for user-visible status messaging.
|
||||
|
||||
- Alert types: `error`, `warning`, `info`, `success`.
|
||||
- Useful properties: `title`, `alert-type`, `dismissable`, `narrow`.
|
||||
- Slots: `icon` for custom leading icon, `action` for custom action content.
|
||||
- Content is announced by screen readers when dynamically displayed.
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
|
||||
|
||||
Use `ha-tooltip` from `src/components/ha-tooltip.ts` for contextual hover help. See `src/components/ha-label.ts` for an example.
|
||||
|
||||
## Panels And Lovelace Cards
|
||||
|
||||
Panels commonly extend `SubscribeMixin(LitElement)` and receive route and narrow-layout properties.
|
||||
|
||||
Lovelace cards should implement `LovelaceCard`, validate config in `setConfig()`, handle loading, error, unavailable, and missing-entity states, and add a configuration editor when needed.
|
||||
|
||||
Cards are user-story surfaces. Support different households, entity types, responsive layouts, and accessible interaction states.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: ha-frontend-contexts
|
||||
description: Home Assistant frontend Lit context and hass migration guidance. Use when adding or changing component state access, replacing hass reads, consuming entity or registry contexts, or reviewing rerender behavior.
|
||||
---
|
||||
|
||||
# HA Frontend Contexts
|
||||
|
||||
Use this skill when a component reads Home Assistant state, registries, localization, services, config, UI data, connection state, or API helpers.
|
||||
|
||||
## Goal
|
||||
|
||||
Move leaf components away from the broad `hass: HomeAssistant` object. Broad `hass` access rerenders components for unrelated changes, hides the data a component depends on, and makes tests harder to mock.
|
||||
|
||||
Container components may keep `hass` when they own it and feed providers. Leaf components should consume the narrowest context that covers their reads.
|
||||
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
- Providers are wired by `contextMixin` on `HassBaseEl`; consumers do not wire providers manually.
|
||||
|
||||
## Context Selection
|
||||
|
||||
| Context | Replaces |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
|
||||
| `registriesContext` | all four registries together |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | entity and attribute formatters |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
|
||||
|
||||
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
|
||||
|
||||
## Consumption Patterns
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity id held on the host:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
|
||||
|
||||
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- The component consumes the narrowest context needed for the data it reads.
|
||||
- A broad `hass` property is kept only when the component is a container or external API requires it.
|
||||
- Entity-scoped reads use the consume helpers rather than ad hoc context transforms.
|
||||
- Context fields are marked `@state()` so updates trigger rendering.
|
||||
- Tests and mocks only provide the data the component actually consumes.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: ha-frontend-review
|
||||
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
---
|
||||
|
||||
# HA Frontend Review
|
||||
|
||||
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
|
||||
|
||||
## Pull Request Body
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
|
||||
|
||||
- Do not omit, reorder, or rewrite template sections.
|
||||
- Check the appropriate "Type of change" box based on the actual change.
|
||||
- Do not check checklist items on behalf of the user.
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
- Explain what the change does for users, not only implementation details.
|
||||
- Use Markdown.
|
||||
|
||||
## Pre-Submission Checklist
|
||||
|
||||
- `yarn lint` passes when practical for the scope.
|
||||
- `yarn test` or focused relevant tests are green when practical for the scope.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
|
||||
- Components handle loading, error, unavailable, and missing-entity states.
|
||||
- Entity existence is checked before property access.
|
||||
- Event listeners and subscriptions are cleaned up.
|
||||
- UI is accessible to screen readers and keyboard users.
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
- Form markup should not cause password managers to identify fields incorrectly.
|
||||
- Clickable areas should be large enough for touch interaction.
|
||||
- Hover, active, disabled, loading, and focus states should be clear.
|
||||
|
||||
Dialog and modal patterns:
|
||||
|
||||
- Multi-step operations should show progress.
|
||||
- Dialog state should survive background operations correctly.
|
||||
- Cancel and close buttons should behave consistently.
|
||||
- Defaults should be helpful without blocking user override.
|
||||
|
||||
Component design patterns:
|
||||
|
||||
- Terminology should be consistent. Use words like "Join" or "Apply" instead of "Group" when that better matches the user action.
|
||||
- Visual hierarchy should use appropriate font sizes, weights, and spacing ratios.
|
||||
- Components should align to the design grid.
|
||||
- Badges and indicators should be placed consistently.
|
||||
|
||||
Code quality:
|
||||
|
||||
- Null and undefined paths should be handled explicitly.
|
||||
- Potentially undefined array and object access should be guarded.
|
||||
- Event handlers, timers, observers, and subscriptions should be cleaned up.
|
||||
|
||||
Configuration and props:
|
||||
|
||||
- Make configuration fields optional when sensible.
|
||||
- Provide reasonable defaults.
|
||||
- Keep APIs extensible without adding speculative abstractions.
|
||||
- Validate configuration before applying changes.
|
||||
|
||||
## Review Flow
|
||||
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: ha-frontend-styling
|
||||
description: Home Assistant frontend styling, theming, spacing, responsive layout, RTL, and View Transitions guidance. Use when editing CSS, layout, motion, or visual component structure.
|
||||
---
|
||||
|
||||
# HA Frontend Styling
|
||||
|
||||
Use this skill when editing CSS, layout, visual hierarchy, theme integration, responsive behavior, RTL support, or view transitions.
|
||||
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers.
|
||||
- Avoid adding new legacy Material Web Components (`mwc-*`).
|
||||
- Scope styles to the component. Do not rely on global styles for component internals.
|
||||
|
||||
Spacing tokens are defined in `src/resources/theme/core.globals.ts`. The scale runs from `--ha-space-1` at 4px through `--ha-space-20` at 80px in 4px increments. Common values are `--ha-space-2` at 8px, `--ha-space-4` at 16px, and `--ha-space-8` at 32px.
|
||||
|
||||
```ts
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction States
|
||||
|
||||
- Make touch targets large enough for mobile.
|
||||
- Provide clear hover, active, focus, disabled, loading, error, and unavailable states.
|
||||
- Preserve keyboard navigation and visible focus indicators.
|
||||
- Maintain WCAG AA contrast for text and essential UI affordances.
|
||||
|
||||
## View Transitions
|
||||
|
||||
Use the View Transitions API only for meaningful continuity between DOM states.
|
||||
|
||||
Core resources:
|
||||
|
||||
- Utility wrapper: `src/common/util/view-transition.ts`, `withViewTransition()`.
|
||||
- Launch-screen fade example: `src/util/launch-screen.ts`.
|
||||
- Animation keyframes: `src/resources/theme/animations.globals.ts`.
|
||||
- Animation duration tokens: `src/resources/theme/core.globals.ts`.
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Use `withViewTransition()` for fallback behavior.
|
||||
- Keep transitions simple. Subtle fades and crossfades usually work best.
|
||||
- Use `--ha-animation-duration-fast`, `--ha-animation-duration-normal`, or `--ha-animation-duration-slow` for timing.
|
||||
- Ensure each `view-transition-name` is unique at any given time.
|
||||
- Remember only one view transition can run at a time.
|
||||
- View transitions operate at document level and do not work inside Shadow DOM style isolation. For web components, set `view-transition-name` on `:host` or use document-level transitions.
|
||||
- The root gets `view-transition-name: root` by default. Target `::view-transition-group(root)` to customize the default page transition.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Styling uses theme variables and spacing tokens where practical.
|
||||
- Layout is mobile-first and RTL-safe.
|
||||
- Component styles are scoped.
|
||||
- Interactive states are clear and accessible.
|
||||
- Motion is subtle, tokenized, and respects reduced-motion behavior through existing globals.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend testing and validation workflow. Use when adding or updating tests, running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
---
|
||||
|
||||
# HA Frontend Testing
|
||||
|
||||
Use this skill when choosing or running validation for frontend changes.
|
||||
|
||||
## Test Helpers
|
||||
|
||||
- Before adding or changing tests, inspect the relevant suite's existing helpers and fixtures. Reuse them instead of duplicating setup, test data, navigation, interactions, waits, or assertions.
|
||||
- When the same test flow appears more than once, move it into the closest suite-local helper with a focused interface.
|
||||
- Keep one-off test behaviour in the test unless a helper makes the intent materially clearer. Do not hide the behaviour under test behind broad, configurable abstractions.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
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 dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
- Mock WebSocket connections and API calls at boundaries.
|
||||
- Cover loading, error, unavailable, and missing-entity states where relevant.
|
||||
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
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
|
||||
|
||||
Each suite has its own dev server port. Prefer running the relevant server in the background while iterating. Playwright reuses it when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| Suite | Background server | Test command |
|
||||
| ------- | -------------------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev --background` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo --background` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery --background` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Use the same server command with `--status`, `--logs [--follow]`, or `--stop` to manage it. Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
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 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.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
|
||||
|
||||
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
|
||||
|
||||
## Verification Selection
|
||||
|
||||
- Documentation-only change: no code test required unless examples or commands changed.
|
||||
- Type-only or utility change: run focused Vitest if available, then `yarn lint:types` if practical.
|
||||
- Lit component change: run relevant tests plus lint or typecheck depending on scope.
|
||||
- E2E-sensitive flow: start the relevant e2e dev server and run the narrow Playwright suite.
|
||||
- Broad refactor: run `yarn lint` and relevant test suites when practical.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: ha-frontend-user-facing-text
|
||||
description: Home Assistant frontend copy, localization, terminology, and user-facing text guidance. Use when adding or reviewing labels, buttons, dialogs, errors, translations, or UI strings.
|
||||
---
|
||||
|
||||
# HA Frontend User-Facing Text
|
||||
|
||||
Use this skill for all user-facing text, translations, labels, buttons, dialog copy, errors, helper text, and review comments about wording.
|
||||
|
||||
## Localization
|
||||
|
||||
- All user-facing text must be translatable.
|
||||
- Add translation keys to `src/translations/en.json` when introducing new strings.
|
||||
- Use the localization system instead of inline user-visible strings.
|
||||
- Prefer complete localized strings with placeholders over concatenating translated fragments.
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
## Voice And Style
|
||||
|
||||
- Use American English.
|
||||
- Use a friendly, informational tone.
|
||||
- Address users directly with "you" and "your" when appropriate.
|
||||
- Be inclusive, objective, and non-discriminatory.
|
||||
- Be concise and clear.
|
||||
- Use active voice.
|
||||
- Avoid jargon where a familiar home automation term works.
|
||||
- Always write "Home Assistant" in full. Do not use "HA" or "HASS" in user-facing copy.
|
||||
- Spell out terms when possible.
|
||||
- Use sentence case for titles, headings, buttons, labels, and UI elements.
|
||||
- Use the Oxford comma in lists.
|
||||
- Prefer "like" over "e.g." and "for example" over "i.e.".
|
||||
- Avoid all caps for emphasis. Use wording, bold, or italics instead.
|
||||
- Write for both technical and non-technical users.
|
||||
|
||||
Sentence case examples:
|
||||
|
||||
- Use: "Create new automation"
|
||||
- Avoid: "Create New Automation"
|
||||
- Use: "Device settings"
|
||||
- Avoid: "Device Settings"
|
||||
|
||||
## Terminology
|
||||
|
||||
Use "integration" instead of "component" for user-facing product language unless referring to a frontend component in developer context.
|
||||
|
||||
Technical product terms are lowercase in prose: automation, entity, device, service.
|
||||
|
||||
## Delete, Remove, Create, Add
|
||||
|
||||
Use "Remove" for actions that can be restored or reapplied:
|
||||
|
||||
- Removing a user's permission.
|
||||
- Removing a user from a group.
|
||||
- Removing links between items.
|
||||
- Removing a widget from a dashboard.
|
||||
- Removing an item from a cart.
|
||||
|
||||
Use "Delete" for permanent, non-recoverable actions:
|
||||
|
||||
- Deleting a field.
|
||||
- Deleting a value in a field.
|
||||
- Deleting a task.
|
||||
- Deleting a group.
|
||||
- Deleting a permission.
|
||||
- Deleting a calendar event.
|
||||
|
||||
Use "Add" for already-existing items:
|
||||
|
||||
- Adding a permission to a user.
|
||||
- Adding a user to a group.
|
||||
- Adding links between items.
|
||||
- Adding a widget to a dashboard.
|
||||
- Adding an item to a cart.
|
||||
|
||||
Use "Create" for something made from scratch:
|
||||
|
||||
- Creating a new field.
|
||||
- Creating a new task.
|
||||
- Creating a new group.
|
||||
- Creating a new permission.
|
||||
- Creating a new calendar event.
|
||||
|
||||
Create pairs with Delete. Add pairs with Remove.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Text is localized.
|
||||
- Copy uses sentence case.
|
||||
- "Home Assistant" is written in full.
|
||||
- Delete/Remove and Create/Add match recoverability and object lifecycle.
|
||||
- Placeholders are used instead of string concatenation.
|
||||
- The wording is concise and understandable without implementation knowledge.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -18,6 +18,10 @@ runs:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
|
||||
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- [Development Standards](#development-standards)
|
||||
- [Component Library](#component-library)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Text and Copy Guidelines](#text-and-copy-guidelines)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Review Guidelines](#review-guidelines)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
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 dev # Dev server (app; --background/--status/--stop/--logs)
|
||||
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
|
||||
### Component Prefixes
|
||||
|
||||
- `ha-` - Home Assistant components
|
||||
- `hui-` - Lovelace UI components
|
||||
- `dialog-` - Dialog components
|
||||
|
||||
### Import Patterns
|
||||
|
||||
```typescript
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
```
|
||||
|
||||
## Core Architecture
|
||||
|
||||
The Home Assistant frontend is a modern web application that:
|
||||
|
||||
- Uses Web Components (custom elements) built with Lit framework
|
||||
- Is written entirely in TypeScript with strict type checking
|
||||
- Communicates with the backend via WebSocket API
|
||||
- Provides comprehensive theming and internationalization
|
||||
|
||||
## State Access: Contexts Instead of `hass`
|
||||
|
||||
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
|
||||
|
||||
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
|
||||
|
||||
### Contexts
|
||||
|
||||
Consume the narrowest context that covers your reads:
|
||||
|
||||
| Context | Replaces |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
|
||||
|
||||
### Consuming
|
||||
|
||||
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For any other single field, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
|
||||
- Prettier with ES5 trailing commas enforced
|
||||
- No console statements (`no-console: "error"`) - use proper logging
|
||||
- Import organization: No unused imports, consistent type imports
|
||||
|
||||
**Naming Conventions**
|
||||
|
||||
- PascalCase for types and classes
|
||||
- camelCase for variables, methods
|
||||
- Private methods require leading underscore
|
||||
- Public methods forbid leading underscore
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
|
||||
- **Proper type imports**: Use `import type` for type-only imports
|
||||
- **Define interfaces**: Create proper interfaces for data structures
|
||||
- **Type component properties**: All Lit properties must be properly typed
|
||||
- **No unused variables**: Prefix with `_` if intentionally unused
|
||||
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface EntityConfig {
|
||||
entity: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
// Bad
|
||||
@property()
|
||||
hass: any;
|
||||
```
|
||||
|
||||
### Web Components with Lit
|
||||
|
||||
- **Use Lit 3.x patterns**: Follow modern Lit practices
|
||||
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
|
||||
- **Define custom element names**: Use `ha-` prefix for components
|
||||
|
||||
```typescript
|
||||
@customElement("ha-my-component")
|
||||
export class HaMyComponent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _config?: MyComponentConfig;
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>Content</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Guidelines
|
||||
|
||||
- **Use composition**: Prefer composition over inheritance
|
||||
- **Lazy load panels**: Heavy panels should be dynamically imported
|
||||
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
|
||||
- **Handle loading states**: Always show appropriate loading indicators
|
||||
- **Support themes**: Use CSS custom properties from theme
|
||||
|
||||
### Data Management
|
||||
|
||||
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- **Cache appropriately**: Use collections and caching for frequently accessed data
|
||||
- **Handle errors gracefully**: All API calls should have error handling
|
||||
- **Update real-time**: Subscribe to state changes for live updates
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
try {
|
||||
const result = await fetchEntityRegistry(this.hass.connection);
|
||||
this._processResult(result);
|
||||
} catch (err) {
|
||||
showAlertDialog(this, {
|
||||
text: `Failed to load: ${err.message}`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Styling Guidelines
|
||||
|
||||
- **Use CSS custom properties**: Leverage the theme system
|
||||
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
|
||||
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### View Transitions
|
||||
|
||||
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
|
||||
|
||||
**Core Resources:**
|
||||
|
||||
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
|
||||
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
|
||||
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
|
||||
|
||||
**Implementation Guidelines:**
|
||||
|
||||
1. Always use `withViewTransition()` wrapper for automatic fallback
|
||||
2. Keep transitions simple (subtle crossfades and fades work best)
|
||||
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
|
||||
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
|
||||
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
|
||||
|
||||
**Default Root Transition:**
|
||||
|
||||
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
|
||||
|
||||
**Important Constraints:**
|
||||
|
||||
- Each `view-transition-name` must be unique at any given time
|
||||
- Only one view transition can run at a time
|
||||
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
|
||||
|
||||
**Specification & Documentation:**
|
||||
|
||||
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
|
||||
|
||||
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
|
||||
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
|
||||
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
|
||||
|
||||
### Performance Best Practices
|
||||
|
||||
- **Code split**: Split code at the panel/dialog level
|
||||
- **Lazy load**: Use dynamic imports for heavy components
|
||||
- **Optimize bundle**: Keep initial bundle size minimal
|
||||
- **Use virtual scrolling**: For long lists, implement virtual scrolling
|
||||
- **Memoize computations**: Cache expensive calculations
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- **Write tests**: Add tests for data processing and utilities
|
||||
- **Test with Vitest**: Use the established test framework
|
||||
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||
- **Test accessibility**: Ensure components are accessible
|
||||
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
|
||||
|
||||
## Component Library
|
||||
|
||||
### Dialog Component
|
||||
|
||||
**Opening Dialogs (Fire Event Pattern - Recommended):**
|
||||
|
||||
```typescript
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
**Dialog Implementation Requirements:**
|
||||
|
||||
- Use `ha-dialog` component
|
||||
- Implement `HassDialog<T>` interface
|
||||
- Use `@state() private _open = false` to control dialog visibility
|
||||
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
|
||||
- Return `nothing` when no params (loading state)
|
||||
- Fire `dialog-closed` event in `_dialogClosed()` handler
|
||||
- Use `header-title` attribute for simple titles
|
||||
- Use `header-subtitle` attribute for simple subtitles
|
||||
- Use slots for custom content where the standard attributes are not enough
|
||||
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
|
||||
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
|
||||
|
||||
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
|
||||
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
|
||||
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
|
||||
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
### Form Component (ha-form)
|
||||
|
||||
- Schema-driven using `HaFormSchema[]`
|
||||
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
|
||||
- Built-in validation with error display
|
||||
- Use `computeLabel`, `computeError`, `computeHelper` for translations
|
||||
|
||||
```typescript
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
- Types: `error`, `warning`, `info`, `success`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
|
||||
- Slots: `icon` (override the leading icon), `action` (custom action content)
|
||||
- Content announced by screen readers when dynamically displayed
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatically blocks shortcuts when input fields are focused
|
||||
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
|
||||
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Class definition**: `src/common/keyboard/shortcuts.ts`
|
||||
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
|
||||
|
||||
### Tooltip Component (ha-tooltip)
|
||||
|
||||
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Panel
|
||||
|
||||
```typescript
|
||||
@customElement("ha-panel-myfeature")
|
||||
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
narrow!: boolean;
|
||||
|
||||
@property()
|
||||
route!: Route;
|
||||
|
||||
hassSubscribe() {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection, (entities) => {
|
||||
this._entities = entities;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Creating a Lovelace Card
|
||||
|
||||
**Purpose**: Cards allow users to tell different stories about their house.
|
||||
|
||||
```typescript
|
||||
@customElement("hui-my-card")
|
||||
export class HuiMyCard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _config?: MyCardConfig;
|
||||
|
||||
public setConfig(config: MyCardConfig): void {
|
||||
if (!config.entity) {
|
||||
throw new Error("Entity required");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 3; // Height in grid units
|
||||
}
|
||||
|
||||
// Optional: Editor for card configuration
|
||||
public static getConfigElement(): LovelaceCardEditor {
|
||||
return document.createElement("hui-my-card-editor");
|
||||
}
|
||||
|
||||
// Optional: Stub config for card picker
|
||||
public static getStubConfig(): object {
|
||||
return { entity: "" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Card Guidelines:**
|
||||
|
||||
- Cards are highly customizable for different households
|
||||
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
|
||||
- Use proper error handling in `setConfig()`
|
||||
- Consider all possible states (loading, error, unavailable)
|
||||
- Support different entity types and states
|
||||
- Follow responsive design principles
|
||||
- Add configuration editor when needed
|
||||
|
||||
### Internationalization
|
||||
|
||||
- **Use localize**: Always use the localization system
|
||||
- **Add translation keys**: Add keys to src/translations/en.json
|
||||
- **Support placeholders**: Use proper placeholder syntax
|
||||
|
||||
```typescript
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **ARIA labels**: Add appropriate ARIA labels
|
||||
- **Keyboard navigation**: Ensure all interactions work with keyboard
|
||||
- **Screen reader support**: Test with screen readers
|
||||
- **Color contrast**: Meet WCAG AA standards
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup and Commands
|
||||
|
||||
1. **Setup**: `script/setup` - Install dependencies
|
||||
2. **Develop**: `script/develop` - Development server
|
||||
3. **Lint**: `yarn lint` - Run all linting before committing
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Dev servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
|
||||
|
||||
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
### End-to-end (e2e) tests
|
||||
|
||||
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
|
||||
|
||||
Start the suite's dev server, then run the suite:
|
||||
|
||||
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
|
||||
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
|
||||
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
|
||||
|
||||
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
|
||||
|
||||
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
|
||||
- Don't manipulate DOM directly - Let Lit handle rendering
|
||||
- Don't use global styles - Scope styles to components
|
||||
- Don't block the main thread - Use web workers for heavy computation
|
||||
- Don't ignore TypeScript errors - Fix all type issues
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
- Sanitize HTML - Never use `unsafeHTML` with user content
|
||||
- Validate inputs - Always validate user inputs
|
||||
- Use HTTPS - All external resources must use HTTPS
|
||||
- CSP compliance - Ensure code works with Content Security Policy
|
||||
|
||||
### Pull Requests
|
||||
|
||||
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately.
|
||||
|
||||
- Do not omit, reorder, or rewrite the template sections
|
||||
- Check the appropriate "Type of change" box based on the changes
|
||||
- Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it
|
||||
- Be simple and user friendly — explain what the change does, not implementation details
|
||||
- Use markdown so the user can copy it
|
||||
|
||||
### Text and Copy Guidelines
|
||||
|
||||
#### Terminology Standards
|
||||
|
||||
**Delete vs Remove**
|
||||
|
||||
- **Use "Remove"** for actions that can be restored or reapplied:
|
||||
- Removing a user's permission
|
||||
- Removing a user from a group
|
||||
- Removing links between items
|
||||
- Removing a widget from dashboard
|
||||
- Removing an item from a cart
|
||||
- **Use "Delete"** for permanent, non-recoverable actions:
|
||||
- Deleting a field
|
||||
- Deleting a value in a field
|
||||
- Deleting a task
|
||||
- Deleting a group
|
||||
- Deleting a permission
|
||||
- Deleting a calendar event
|
||||
|
||||
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
|
||||
|
||||
- **Use "Add"** for already-existing items:
|
||||
- Adding a permission to a user
|
||||
- Adding a user to a group
|
||||
- Adding links between items
|
||||
- Adding a widget to dashboard
|
||||
- Adding an item to a cart
|
||||
- **Use "Create"** for something made from scratch:
|
||||
- Creating a new field
|
||||
- Creating a new task
|
||||
- Creating a new group
|
||||
- Creating a new permission
|
||||
- Creating a new calendar event
|
||||
|
||||
#### Writing Style (Consistent with Home Assistant Documentation)
|
||||
|
||||
- **Use American English**: Standard spelling and terminology
|
||||
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
|
||||
- **Address users directly**: Use "you" and "your"
|
||||
- **Be inclusive**: Objective, non-discriminatory language
|
||||
- **Be concise**: Use clear, direct language
|
||||
- **Be consistent**: Follow established terminology patterns
|
||||
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
|
||||
- **Avoid jargon**: Use terms familiar to home automation users
|
||||
|
||||
#### Language Standards
|
||||
|
||||
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
|
||||
- **Avoid abbreviations**: Spell out terms when possible
|
||||
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
|
||||
- ✅ "Create new automation"
|
||||
- ❌ "Create New Automation"
|
||||
- ✅ "Device settings"
|
||||
- ❌ "Device Settings"
|
||||
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
|
||||
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
|
||||
- **Avoid CAPS for emphasis**: Use bold or italics instead
|
||||
- **Write for all skill levels**: Both technical and non-technical users
|
||||
|
||||
#### Key Terminology
|
||||
|
||||
- **"integration"** (preferred over "component")
|
||||
- **Technical terms**: Use lowercase (automation, entity, device, service)
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
|
||||
|
||||
- **Keep context**: Provide enough context for translators
|
||||
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
|
||||
|
||||
#### User Experience and Accessibility
|
||||
|
||||
- **Form validation**: Always provide proper field labels and validation feedback
|
||||
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
|
||||
- **Hit targets**: Make clickable areas large enough for touch interaction
|
||||
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
|
||||
|
||||
#### Dialog and Modal Patterns
|
||||
|
||||
- **Interview progress**: Show clear progress for multi-step operations
|
||||
- **State persistence**: Handle dialog state properly during background operations
|
||||
- **Cancel behavior**: Ensure cancel/close buttons work consistently
|
||||
- **Form prefilling**: Use smart defaults but allow user override
|
||||
|
||||
#### Component Design Patterns
|
||||
|
||||
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
|
||||
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
|
||||
- **Grid alignment**: Components should align to the design grid system
|
||||
- **Badge placement**: Position badges and indicators consistently
|
||||
|
||||
#### Code Quality Issues
|
||||
|
||||
- **Null checking**: Always check if entities exist before accessing properties
|
||||
- **TypeScript safety**: Handle potentially undefined array/object access
|
||||
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
|
||||
|
||||
#### Configuration and Props
|
||||
|
||||
- **Optional parameters**: Make configuration fields optional when sensible
|
||||
- **Smart defaults**: Provide reasonable default values
|
||||
- **Future extensibility**: Design APIs that can be extended later
|
||||
- **Validation**: Validate configuration before applying changes
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
|
||||
|
||||
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
|
||||
- [ ] Tests added for new data processing/utilities (where applicable)
|
||||
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event/subscription listeners cleaned up (no memory leaks)
|
||||
- [ ] Accessible to screen readers and keyboard
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
+161
-40
@@ -38,8 +38,9 @@ jobs:
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
target: build-demo-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -65,8 +66,9 @@ jobs:
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
target: build-e2e-test-app-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -103,15 +105,27 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
# ── Run Playwright tests against Chromium ──────────────────────────────────
|
||||
e2e-demo:
|
||||
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-demo
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
shardTotal:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -121,60 +135,133 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Run Playwright demo tests
|
||||
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/demo/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-app:
|
||||
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-e2e-test-app
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Run Playwright app tests
|
||||
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/app/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-gallery:
|
||||
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-gallery
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
- name: Run Playwright gallery tests
|
||||
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload blob report
|
||||
- name: Upload gallery blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/gallery/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
needs:
|
||||
- e2e-demo
|
||||
- e2e-app
|
||||
- e2e-gallery
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ always() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -187,12 +274,26 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download blob report (local)
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
path: test/e2e/reports/gallery/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
@@ -209,7 +310,11 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(needs.e2e-demo.result == 'failure' ||
|
||||
needs.e2e-app.result == 'failure' ||
|
||||
needs.e2e-gallery.result == 'failure')
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -217,3 +322,19 @@ jobs:
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
|
||||
- name: Check suite results
|
||||
run: |
|
||||
failed=0
|
||||
for suite in \
|
||||
"demo:${{ needs.e2e-demo.result }}" \
|
||||
"app:${{ needs.e2e-app.result }}" \
|
||||
"gallery:${{ needs.e2e-gallery.result }}"; do
|
||||
name="${suite%%:*}"
|
||||
result="${suite#*:}"
|
||||
echo "E2E ${name}: ${result}"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
+4
-1
@@ -61,9 +61,12 @@ test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
# MCP server
|
||||
.playwright-mcp/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.claude/*
|
||||
!.claude/skills
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
+144
-144
File diff suppressed because one or more lines are too long
+1
-1
@@ -13,4 +13,4 @@ nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.17.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.17.1.cjs
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Home Assistant Frontend Agent Guide
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
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 dev # App dev server, supports --background/--status/--stop/--logs
|
||||
yarn dev:serve # Local serving dev server, supports -c core URL, -p port, and dev flags
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives file arguments, it ignores `tsconfig.json` and can emit `.js` files into `src/`. Always run `yarn lint:types` without arguments. For individual file type checking, rely on editor diagnostics.
|
||||
|
||||
## Architecture
|
||||
|
||||
- The frontend uses custom elements built with Lit and TypeScript strict mode.
|
||||
- Components communicate with the backend through the Home Assistant WebSocket API.
|
||||
- Use `ha-` for Home Assistant components, `hui-` for Lovelace UI components, and `dialog-` for dialogs.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers. Avoid adding new legacy `mwc-*` usage.
|
||||
- Leaf components should consume narrow Lit contexts instead of taking the broad `hass` object unless they are containers that own and provide `hass`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
- Use strict TypeScript, proper interfaces, and `import type` for type-only imports.
|
||||
- Avoid `any`; model data with existing Home Assistant types or narrow new types.
|
||||
- Keep imports organized and remove unused imports.
|
||||
- Do not use `console`; use existing logging or user-visible error patterns.
|
||||
- Use `@state()` for internal Lit state and `@property()` for public API.
|
||||
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
|
||||
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
|
||||
- All user-facing text must be localized through the translation system.
|
||||
|
||||
## Project Skills
|
||||
|
||||
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
|
||||
|
||||
- `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-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.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
@@ -232,7 +232,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -247,6 +247,7 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -306,7 +307,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -321,6 +322,7 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,22 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-demo-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo-e2e",
|
||||
"gen-pages-demo-prod-e2e"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-demo",
|
||||
gulp.series(
|
||||
|
||||
@@ -39,3 +39,18 @@ gulp.task(
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app-e2e",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -225,6 +225,16 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-prod-e2e",
|
||||
genPagesProdTask(
|
||||
DEMO_PAGE_ENTRIES,
|
||||
paths.demo_dir,
|
||||
paths.demo_output_root,
|
||||
paths.demo_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
|
||||
|
||||
gulp.task(
|
||||
|
||||
@@ -177,6 +177,18 @@ gulp.task("rspack-prod-demo", () =>
|
||||
bothBuilds(createDemoConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-demo-e2e", () =>
|
||||
prodBuild(
|
||||
createDemoConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -269,6 +281,18 @@ gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
|
||||
prodBuild(
|
||||
createE2eTestAppConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -387,9 +387,14 @@ const createAppConfig = ({
|
||||
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
const createDemoConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
createRspackConfig(
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
@@ -401,9 +406,19 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
const createE2eTestAppConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
bundle.config.e2eTestApp({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -22,7 +22,7 @@ export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to is_onboarding_postponed); hides
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
|
||||
@@ -57,7 +57,7 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
is_onboarding_postponed: false,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
@@ -124,7 +124,7 @@ const applyScenario = () => {
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.is_onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
@@ -165,7 +165,7 @@ const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.is_onboarding_postponed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
@@ -201,7 +201,7 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.is_onboarding_postponed = true;
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
|
||||
+19
-19
@@ -48,19 +48,19 @@
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.0",
|
||||
"@codemirror/view": "6.43.5",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
"@formatjs/intl-displaynames": "7.3.10",
|
||||
"@formatjs/intl-durationformat": "0.10.15",
|
||||
"@formatjs/intl-datetimeformat": "7.4.10",
|
||||
"@formatjs/intl-displaynames": "7.3.11",
|
||||
"@formatjs/intl-durationformat": "0.10.16",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.10",
|
||||
"@formatjs/intl-listformat": "8.3.11",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.11",
|
||||
"@formatjs/intl-pluralrules": "6.3.10",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.12",
|
||||
"@formatjs/intl-pluralrules": "6.3.11",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.11",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -106,8 +106,8 @@
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.6",
|
||||
"intl-messageformat": "11.2.9",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.10",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -115,7 +115,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.5",
|
||||
"marked": "18.0.6",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -151,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.17",
|
||||
"@rspack/core": "2.1.2",
|
||||
"@rsdoctor/rspack-plugin": "1.5.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -168,7 +168,7 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
@@ -202,7 +202,7 @@
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.4",
|
||||
"prettier": "3.9.5",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
@@ -210,9 +210,9 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.62.1",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
@@ -228,7 +228,7 @@
|
||||
"@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",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.17.0",
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 8.8 KiB |
@@ -58,6 +58,17 @@
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"description": "Keep Playwright CI container image up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
@@ -86,6 +97,11 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { Context, ContextCallback } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { ReactiveController, ReactiveElement } from "lit";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
|
||||
return previous;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reactive controller that subscribes to a Lit context and assigns each
|
||||
* delivered value to a host property — WITHOUT forcing a host update on every
|
||||
* delivery.
|
||||
*
|
||||
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
|
||||
* unconditionally on every provider notification. For a hot context such as
|
||||
* `statesContext` (replaced on every entity state change) that means every
|
||||
* consumer runs an (often empty) update/render cycle on every unrelated state
|
||||
* change, even when the value it actually reads is unchanged.
|
||||
*
|
||||
* This controller instead leaves update scheduling to the property's own
|
||||
* setter. Combined with {@link transform}, that setter only requests an update
|
||||
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
|
||||
* with `hasChanged`), so unrelated context churn no longer triggers renders.
|
||||
*/
|
||||
class ContextSubscriptionController<ValueType> implements ReactiveController {
|
||||
private _unsubscribe?: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly _host: ReactiveElement,
|
||||
private readonly _context: Context<unknown, ValueType>,
|
||||
private readonly _assign: (value: ValueType) => void
|
||||
) {
|
||||
this._host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.dispatchEvent(
|
||||
new ContextEvent(this._context, this._host, this._callback, true)
|
||||
);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = undefined;
|
||||
}
|
||||
|
||||
// Class field arrow function so the identity is stable per instance, which the
|
||||
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
|
||||
private readonly _callback: ContextCallback<ValueType> = (
|
||||
value,
|
||||
unsubscribe
|
||||
) => {
|
||||
// A different provider answered (e.g. re-parenting); drop the stale one.
|
||||
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
|
||||
this._unsubscribe();
|
||||
}
|
||||
this._unsubscribe = unsubscribe;
|
||||
// Assign through the property setter, which decides — via `hasChanged` —
|
||||
// whether an update is actually needed. We intentionally never call
|
||||
// `host.requestUpdate()` here.
|
||||
this._assign(value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
|
||||
* host update on every provider notification — see
|
||||
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
|
||||
* update is scheduled only when the derived value actually changes.
|
||||
*/
|
||||
const subscribeContext =
|
||||
<ValueType>(context: Context<unknown, ValueType>) =>
|
||||
(proto: object, propertyKey: string): void => {
|
||||
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
|
||||
(host) => {
|
||||
new ContextSubscriptionController<ValueType>(
|
||||
host as ReactiveElement,
|
||||
context,
|
||||
(value) => {
|
||||
(host as unknown as Record<string, unknown>)[propertyKey] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
context: Context<unknown, T>,
|
||||
watchKey: string | undefined,
|
||||
select: (this: unknown, value: T) => V | undefined
|
||||
) => {
|
||||
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({ context, subscribe: true });
|
||||
const consumeDec = subscribeContext<T>(context);
|
||||
return (proto: any, propertyKey: string) => {
|
||||
transformDec(proto, propertyKey);
|
||||
consumeDec(proto, propertyKey);
|
||||
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
const consumeDec = subscribeContext<HassEntities>(statesContext);
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
|
||||
/**
|
||||
* Consumes `internationalizationContext` and narrows it to the `localize`
|
||||
* function. No host watching is needed — the decorated property updates
|
||||
* whenever the i18n context changes.
|
||||
* whenever `localize` changes.
|
||||
*/
|
||||
export const consumeLocalize = () =>
|
||||
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
|
||||
|
||||
@@ -64,6 +64,23 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (path.includes("#")) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const constructUrlCurrentPath = (searchParams: string): string => {
|
||||
const base = window.location.pathname;
|
||||
const hash = __DEMO__ ? window.location.hash : "";
|
||||
// Prevent trailing "?" if no parameters exist
|
||||
return searchParams ? base + "?" + searchParams : base;
|
||||
return `${searchParams ? `${base}?${searchParams}` : base}${hash}`;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { BarSeriesOption } from "echarts/types/dist/shared";
|
||||
|
||||
/**
|
||||
* `extraBuckets` (only used when `stacked`) seeds the bucket union with the
|
||||
* expected statistics grid so sparse datasets get zero-filled across the whole
|
||||
* range, including past their last real point. Without it, buckets are only
|
||||
* derived from the data and trailing buckets are never filled (legacy
|
||||
* behavior, kept for callers that don't pass a grid).
|
||||
*/
|
||||
export function fillDataGapsAndRoundCaps(
|
||||
datasets: BarSeriesOption[],
|
||||
stacked = true
|
||||
stacked = true,
|
||||
extraBuckets?: number[]
|
||||
) {
|
||||
if (!stacked) {
|
||||
// For non-stacked charts, we can simply apply an overall border to each stack
|
||||
@@ -44,6 +52,7 @@ export function fillDataGapsAndRoundCaps(
|
||||
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||
)
|
||||
.flat()
|
||||
.concat(extraBuckets ?? [])
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
@@ -61,9 +70,18 @@ export function fillDataGapsAndRoundCaps(
|
||||
const x = item.value?.[0];
|
||||
const stack = datasets[i].stack ?? "";
|
||||
if (x === undefined) {
|
||||
continue;
|
||||
// Past the end of this dataset's data. Only append trailing buckets
|
||||
// when an explicit grid was provided; originally-empty datasets
|
||||
// (e.g. compare placeholders) stay empty either way.
|
||||
if (
|
||||
dataPoint !== undefined ||
|
||||
extraBuckets === undefined ||
|
||||
!datasets[i].data!.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (Number(x) !== bucket) {
|
||||
if (x === undefined || Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, {
|
||||
value: [bucket, 0],
|
||||
itemStyle: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CLIMATE_MODE_CONFIGS,
|
||||
generateStateHistoryChartLineData,
|
||||
} from "./state-history-chart-line-data";
|
||||
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -28,6 +29,10 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
|
||||
|
||||
// Minimum width reserved for the Y-axis labels; also the value _yWidth is
|
||||
// re-measured up from whenever the tick precision changes on zoom.
|
||||
const MIN_Y_AXIS_WIDTH = 25;
|
||||
|
||||
// Used to recover the underlying entity_id from a legend dataset id.
|
||||
// Kept in sync with the suffixes appended at dataset construction below
|
||||
// for climate / water_heater / humidifier multi-attribute charts.
|
||||
@@ -101,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@state() private _yWidth = 25;
|
||||
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
|
||||
|
||||
@state() private _visualMap?: VisualMapComponentOption[];
|
||||
|
||||
@@ -318,8 +323,18 @@ export class StateHistoryChartLine extends LitElement {
|
||||
yAxis: {
|
||||
type: this.logarithmicScale ? "log" : "value",
|
||||
name: this.unit,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
// Re-measure the gutter for the new precision so it can shrink
|
||||
// again when zooming back out (_yWidth otherwise only grows).
|
||||
this._yWidth = 0;
|
||||
}
|
||||
},
|
||||
}),
|
||||
position: rtl ? "right" : "left",
|
||||
scale: true,
|
||||
nameGap: 2,
|
||||
@@ -448,7 +463,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
});
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
const width = Math.max(measureTextWidth(label, 12) + 5, MIN_Y_AXIS_WIDTH);
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
|
||||
@@ -34,6 +34,7 @@ import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { generateStatisticsChartData } from "./statistics-chart-data";
|
||||
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -391,6 +392,11 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const yAxisScale =
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: [
|
||||
{
|
||||
@@ -434,13 +440,17 @@ export class StatisticsChart extends LitElement {
|
||||
)
|
||||
? "right"
|
||||
: "left",
|
||||
scale:
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined,
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
scale: yAxisScale,
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
onFractionDigits: (digits) => {
|
||||
this._yAxisFractionDigits = digits;
|
||||
},
|
||||
}),
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We estimate the tick interval as `range / 10` (twice
|
||||
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
|
||||
// intervals), then derive `ceil(-log10(interval))`.
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
type YAxisBound =
|
||||
number | ((values: YAxisExtentValues) => number | undefined) | undefined;
|
||||
|
||||
const resolveYAxisBound = (
|
||||
bound: YAxisBound,
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
|
||||
// rendered ticks span from 0 even when the data does not. Union the extent
|
||||
// with 0 to match the labels ECharts actually draws.
|
||||
includeZero?: boolean;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
} {
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
return {
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
let extentMin = resolvedMin ?? values.min;
|
||||
let extentMax = resolvedMax ?? values.max;
|
||||
if (includeZero) {
|
||||
extentMin = Math.min(extentMin, 0);
|
||||
extentMax = Math.max(extentMax, 0);
|
||||
}
|
||||
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -15,7 +16,12 @@ import {
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
|
||||
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
.disabled=${!this._automations || this._automations.length === 0}
|
||||
.getItems=${this._getItems(value, this._automations)}
|
||||
@value-changed=${this._automationChanged}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.actions.unknown_action"
|
||||
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
|
||||
}
|
||||
);
|
||||
|
||||
// Device automation labels (entity name + subtype) are often longer than the
|
||||
// field, so let the option wrap onto multiple lines instead of truncating.
|
||||
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact multiline>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const automation = this._automations?.find(
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
|
||||
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
@property({ type: Boolean, reflect: true, attribute: "border-top" })
|
||||
public borderTop = false;
|
||||
|
||||
// Allow the headline/supporting text to wrap onto multiple lines instead of
|
||||
// truncating with an ellipsis. Off by default to preserve single-line rows.
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public multiline = false;
|
||||
|
||||
static override styles = [
|
||||
...haMdListStyles,
|
||||
css`
|
||||
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
font-size: var(--ha-font-size-s);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host([multiline]) [slot="headline"],
|
||||
:host([multiline]) [slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
::slotted(state-badge),
|
||||
::slotted(img) {
|
||||
width: 32px;
|
||||
|
||||
@@ -20,18 +20,16 @@ class HaRelativeTime extends ReactiveElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _interval?: number;
|
||||
private _timeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._clearInterval();
|
||||
this._clearTimeout();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
@@ -40,31 +38,19 @@ class HaRelativeTime extends ReactiveElement {
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
super.update(changedProps);
|
||||
if (changedProps.has("datetime")) {
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
} else {
|
||||
this._clearInterval();
|
||||
}
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
window.clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
private _clearTimeout(): void {
|
||||
if (this._timeout) {
|
||||
window.clearTimeout(this._timeout);
|
||||
this._timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
|
||||
// update every 60 seconds
|
||||
this._interval = window.setInterval(() => this._updateRelative(), 60000);
|
||||
}
|
||||
|
||||
private _updateRelative(): void {
|
||||
this._clearTimeout();
|
||||
|
||||
if (!this._i18n) {
|
||||
return;
|
||||
}
|
||||
@@ -73,23 +59,33 @@ class HaRelativeTime extends ReactiveElement {
|
||||
this.textContent = this._i18n.localize(
|
||||
"ui.components.relative_time.never"
|
||||
);
|
||||
} else {
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
return;
|
||||
}
|
||||
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
|
||||
// Keep the relative time counting up on its own. Refresh every second
|
||||
// while the difference is still measured in seconds, otherwise every
|
||||
// minute.
|
||||
const secondsDiff = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
this._timeout = window.setTimeout(
|
||||
() => this._updateRelative(),
|
||||
secondsDiff < 60 ? 1000 : 60000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
@query("wa-input")
|
||||
private _input?: WaInput;
|
||||
|
||||
private _startSlotResizeObserver?: ResizeObserver;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected i18n?: ContextType<typeof internationalizationContext>;
|
||||
@@ -167,9 +169,15 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
// Wait for wa-input to finish its first render
|
||||
await this._input?.updateComplete;
|
||||
this._syncStartSlotWidth();
|
||||
this._observeStartSlot();
|
||||
}
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._startSlotResizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const hasLabelSlot = this.label
|
||||
? false
|
||||
@@ -291,6 +299,25 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// Safari can report the start-slot width as 0 during the first render, which
|
||||
// leaves the floating label overlapping the start icon (e.g. the magnify icon
|
||||
// in ha-input-search). Re-sync whenever the wrapper's size changes
|
||||
// (0 -> icon width, or hidden -> shown) so the label padding stays correct.
|
||||
private _observeStartSlot() {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
const startEl = this._input?.shadowRoot?.querySelector('[part~="start"]');
|
||||
if (!startEl) {
|
||||
return;
|
||||
}
|
||||
this._startSlotResizeObserver?.disconnect();
|
||||
this._startSlotResizeObserver = new ResizeObserver(() =>
|
||||
this._syncStartSlotWidth()
|
||||
);
|
||||
this._startSlotResizeObserver.observe(startEl);
|
||||
}
|
||||
|
||||
private _syncStartSlotWidth = () => {
|
||||
const startEl = this._input?.shadowRoot?.querySelector(
|
||||
'[part~="start"]'
|
||||
|
||||
+4
-3
@@ -360,9 +360,10 @@ export const cloudBackupHealth = (
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const next = backupConfig?.next_automatic_backup
|
||||
? new Date(backupConfig.next_automatic_backup).getTime()
|
||||
: 0;
|
||||
const next =
|
||||
backupConfig?.next_automatic_backup &&
|
||||
new Date(backupConfig.next_automatic_backup).getTime();
|
||||
|
||||
if (next && next < Date.now() - BACKUP_OVERDUE_MARGIN_MS) {
|
||||
return "old";
|
||||
}
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export interface CloudStatusLoggedIn {
|
||||
remote_certificate_status: RemoteCertificateStatus | null;
|
||||
http_use_ssl: boolean;
|
||||
active_subscription: boolean;
|
||||
is_onboarding_postponed: boolean;
|
||||
onboarding_postponed: boolean;
|
||||
onboarding_completed: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,23 @@ export interface ActionHandlerOptions {
|
||||
hasHold?: boolean;
|
||||
hasDoubleClick?: boolean;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Only listen for keyboard activation; pointer gestures are handled
|
||||
* elsewhere (a container binding with `resolve` owns them).
|
||||
*/
|
||||
keyboardOnly?: boolean;
|
||||
/**
|
||||
* Container binding: resolve a pointer gesture at viewport coordinates
|
||||
* (x, y) to the element that should receive the action. Returning null
|
||||
* leaves the event alone. The resolver runs at both the start and the end
|
||||
* of a gesture; a release resolving to a different target aborts it.
|
||||
*/
|
||||
resolve?: (x: number, y: number, ev: Event) => ActionHandlerResolution | null;
|
||||
}
|
||||
|
||||
export interface ActionHandlerResolution {
|
||||
target: HTMLElement;
|
||||
options: ActionHandlerOptions;
|
||||
}
|
||||
|
||||
export interface ActionHandlerDetail {
|
||||
|
||||
@@ -117,10 +117,14 @@ export interface SetZwaveUserParams {
|
||||
user_type?: string;
|
||||
credential_rule?: string;
|
||||
active?: boolean;
|
||||
credential_type?: ZwaveCredentialType;
|
||||
credential_slot?: number;
|
||||
credential_data?: string;
|
||||
}
|
||||
|
||||
export interface SetZwaveUserResult {
|
||||
user_id: number;
|
||||
credential_slot?: number | null;
|
||||
}
|
||||
|
||||
export interface SetZwaveCredentialParams {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
subscribeDataEntryFlowProgressed,
|
||||
} from "../../data/data_entry_flow";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
@@ -74,7 +75,10 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("dialog-data-entry-flow")
|
||||
class DataEntryFlowDialog extends LitElement {
|
||||
class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
Record<string, unknown>,
|
||||
"form"
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: DataEntryFlowDialogParams;
|
||||
@@ -99,6 +103,8 @@ class DataEntryFlowDialog extends LitElement {
|
||||
|
||||
@state() private _createEntryHasPendingUpdates = false;
|
||||
|
||||
@state() private _flowHasProgressed = false;
|
||||
|
||||
private _formStepRef = createRef<FormStepElement>();
|
||||
|
||||
private _abortStepRef = createRef<AbortStepElement>();
|
||||
@@ -108,6 +114,8 @@ class DataEntryFlowDialog extends LitElement {
|
||||
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
|
||||
|
||||
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
this._flowHasProgressed = Boolean(params.continueFlowId);
|
||||
this._params = params;
|
||||
this._instance = instance++;
|
||||
this._open = true;
|
||||
@@ -175,7 +183,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._processStep(step);
|
||||
this._processStep(step, this._flowHasProgressed);
|
||||
this._loading = undefined;
|
||||
}
|
||||
|
||||
@@ -213,6 +221,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
|
||||
this._loading = undefined;
|
||||
this._step = undefined;
|
||||
this._flowHasProgressed = false;
|
||||
this._params = undefined;
|
||||
this._handler = undefined;
|
||||
if (this._unsubDataEntryFlowProgress) {
|
||||
@@ -334,7 +343,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this._preventScrimClose}
|
||||
@after-show=${this._focusFormStep}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
@@ -484,6 +493,18 @@ class DataEntryFlowDialog extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private get _preventScrimClose(): boolean {
|
||||
return (
|
||||
this.isDirtyState ||
|
||||
this._flowHasProgressed ||
|
||||
this._loading !== undefined ||
|
||||
this._formStepLoading ||
|
||||
this._createEntryHasPendingUpdates ||
|
||||
this._step?.type === "external" ||
|
||||
this._step?.type === "progress"
|
||||
);
|
||||
}
|
||||
|
||||
private _renderFooter() {
|
||||
if (!this._step || this._loading) {
|
||||
return nothing;
|
||||
@@ -588,13 +609,15 @@ class DataEntryFlowDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async _processStep(
|
||||
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
|
||||
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>,
|
||||
flowHasProgressed = true
|
||||
): Promise<void> {
|
||||
if (step === undefined) {
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
this._flowHasProgressed ||= flowHasProgressed;
|
||||
const delayedLoading = setTimeout(() => {
|
||||
// only show loading for slow steps to avoid flickering
|
||||
this._loading = "loading_step";
|
||||
@@ -615,11 +638,11 @@ class DataEntryFlowDialog extends LitElement {
|
||||
clearTimeout(delayedLoading);
|
||||
this._loading = undefined;
|
||||
}
|
||||
|
||||
this._step = undefined;
|
||||
this._formStepLoading = false;
|
||||
this._createEntryHasPendingUpdates = false;
|
||||
await this.updateComplete;
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
this._step = _step;
|
||||
if (
|
||||
(_step.type === "create_entry" || _step.type === "abort") &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -16,6 +17,10 @@ import type {
|
||||
import "../../components/ha-markdown";
|
||||
import "../../components/ha-spinner";
|
||||
import { autocompleteLoginFields } from "../../data/auth";
|
||||
import {
|
||||
dirtyStateContext,
|
||||
type DirtyStateContext,
|
||||
} from "../../data/context/dirty-state";
|
||||
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
|
||||
import { previewModule } from "../../data/preview";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -49,6 +54,10 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
@state() private _errorMsg?: string;
|
||||
|
||||
@consume({ context: dirtyStateContext, subscribe: true })
|
||||
@state()
|
||||
private _dirtyState?: DirtyStateContext<Record<string, unknown>, "form">;
|
||||
|
||||
private _errors?: Record<string, string>;
|
||||
|
||||
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
|
||||
@@ -150,6 +159,7 @@ class StepFlowForm extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("keydown", this._handleKeyDown);
|
||||
this._dirtyState?.setState(this._stepDataProcessed, "form");
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
@@ -303,6 +313,7 @@ class StepFlowForm extends LitElement {
|
||||
ev: ValueChangedEvent<Record<string, unknown>>
|
||||
): void {
|
||||
this._stepData = ev.detail.value;
|
||||
this._dirtyState?.setState(this._stepData, "form");
|
||||
}
|
||||
|
||||
private _labelCallback = (field: HaFormSchema, _data, options): string =>
|
||||
|
||||
@@ -257,10 +257,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
|
||||
static styles = [
|
||||
css`
|
||||
.shortcut {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -2686,6 +2686,7 @@ class DialogAddAutomationElement
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
direction: ltr;
|
||||
--mdc-icon-size: var(--ha-space-3);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const automationScriptEditorStyles: CSSResult = css`
|
||||
width: 12px;
|
||||
}
|
||||
ha-tooltip .shortcut {
|
||||
direction: ltr;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -269,6 +269,7 @@ export const overflowStyles = css`
|
||||
white-space: nowrap;
|
||||
}
|
||||
.overflow-label .shortcut {
|
||||
direction: ltr;
|
||||
--mdc-icon-size: 12px;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -33,7 +33,7 @@ export class CloudAccountOnboarding extends LitElement {
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card outlined class="onboarding-card">
|
||||
<ha-card outlined>
|
||||
<div class="card-content ready-card">
|
||||
<div class="ready-left">
|
||||
<h2>
|
||||
@@ -146,15 +146,16 @@ export class CloudAccountOnboarding extends LitElement {
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.ready-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-8);
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6);
|
||||
}
|
||||
.ready-left {
|
||||
flex: 1.1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
@@ -167,9 +168,6 @@ export class CloudAccountOnboarding extends LitElement {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.ready-left p {
|
||||
margin: 0;
|
||||
}
|
||||
.ready-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -177,16 +175,13 @@ export class CloudAccountOnboarding extends LitElement {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.ready-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--ha-space-4) var(--ha-space-4);
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ready-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--ha-space-5);
|
||||
@container (max-width: 450px) {
|
||||
.ready-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.ready-chip {
|
||||
|
||||
@@ -119,7 +119,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
private get _onboarded(): boolean {
|
||||
return (
|
||||
this.cloudStatus.onboarding_completed ||
|
||||
this.cloudStatus.is_onboarding_postponed
|
||||
this.cloudStatus.onboarding_postponed
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
|
||||
></ha-input>
|
||||
<ha-area-picker
|
||||
.device=${this.device.device_reg_id}
|
||||
.value=${this.device.area_id ?? undefined}
|
||||
@value-changed=${this._areaPicked}
|
||||
></ha-area-picker>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiOpenInNew } from "@mdi/js";
|
||||
import { mdiChevronRight } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -183,7 +183,7 @@ export class ZHADeviceEndpointList extends LitElement {
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
.path=${mdiOpenInNew}
|
||||
.path=${mdiChevronRight}
|
||||
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.zha.groups.open_device"
|
||||
@@ -211,7 +211,7 @@ export class ZHADeviceEndpointList extends LitElement {
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
.path=${mdiOpenInNew}
|
||||
.path=${mdiChevronRight}
|
||||
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.zha.groups.open_device"
|
||||
|
||||
@@ -227,10 +227,6 @@ export class ZHAGroupPage extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
hass-subpage {
|
||||
--app-header-text-color: var(--sidebar-icon-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
box-sizing: border-box;
|
||||
max-width: 720px;
|
||||
|
||||
+5
-17
@@ -19,7 +19,6 @@ import {
|
||||
DEFAULT_CREDENTIAL_MIN_LENGTH,
|
||||
ENTERABLE_ZWAVE_CREDENTIAL_TYPES,
|
||||
deleteZwaveCredential,
|
||||
deleteZwaveUser,
|
||||
enterableCredentialTypes,
|
||||
getCredentialError,
|
||||
compatibleUserTypes,
|
||||
@@ -539,27 +538,16 @@ class DialogZwaveCredentialUserEdit extends DirtyStateProviderMixin<CredentialFo
|
||||
credentialType: ZwaveCredentialType
|
||||
): Promise<void> {
|
||||
const params = this._params!;
|
||||
const { user_id } = await setZwaveUser(this.hass, params.entity_id, {
|
||||
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
|
||||
user_type: this._userType,
|
||||
active: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await setZwaveCredential(this.hass, params.entity_id, {
|
||||
user_id,
|
||||
await setZwaveUser(this.hass, params.entity_id, {
|
||||
// Omit user_id to create a new user with the given credential.
|
||||
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
|
||||
user_type: this._userType,
|
||||
active: true,
|
||||
credential_type: credentialType,
|
||||
credential_data: this._credentialData,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Roll back the user so the lock returns to its prior state. We
|
||||
// ignore rollback errors — the credential error is the actionable
|
||||
// one to surface; a stranded user will reappear on next refresh.
|
||||
try {
|
||||
await deleteZwaveUser(this.hass, params.entity_id, user_id);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.zwave_js.credentials.errors.add_user_failed",
|
||||
{
|
||||
|
||||
@@ -794,6 +794,8 @@ class HaLogbookEntry extends LitElement {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
inset-inline-end: -1px;
|
||||
inset-inline-start: initial;
|
||||
z-index: 2;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
@@ -910,7 +912,7 @@ class HaLogbookEntry extends LitElement {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { PictureCardConfig } from "../cards/types";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const pictureCardSuggestions: CardSuggestionProvider<PictureCardConfig> =
|
||||
{
|
||||
getEntitySuggestion(_hass, entityId) {
|
||||
if (computeDomain(entityId) !== "image") return null;
|
||||
return {
|
||||
config: {
|
||||
type: "picture",
|
||||
image_entity: entityId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestion
|
||||
import { humidifierCardSuggestions } from "./hui-humidifier-card-suggestions";
|
||||
import { mapCardSuggestions } from "./hui-map-card-suggestions";
|
||||
import { mediaControlCardSuggestions } from "./hui-media-control-card-suggestions";
|
||||
import { pictureCardSuggestions } from "./hui-picture-card-suggestions";
|
||||
import { pictureEntityCardSuggestions } from "./hui-picture-entity-card-suggestions";
|
||||
import { plantStatusCardSuggestions } from "./hui-plant-status-card-suggestions";
|
||||
import { statisticsGraphCardSuggestions } from "./hui-statistics-graph-card-suggestions";
|
||||
@@ -23,6 +24,7 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
|
||||
humidifier: humidifierCardSuggestions,
|
||||
map: mapCardSuggestions,
|
||||
"media-control": mediaControlCardSuggestions,
|
||||
picture: pictureCardSuggestions,
|
||||
"picture-entity": pictureEntityCardSuggestions,
|
||||
"plant-status": plantStatusCardSuggestions,
|
||||
"statistics-graph": statisticsGraphCardSuggestions,
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
startOfMonth,
|
||||
addYears,
|
||||
addMonths,
|
||||
addMinutes,
|
||||
addHours,
|
||||
startOfDay,
|
||||
addDays,
|
||||
subDays,
|
||||
} from "date-fns";
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
CallbackDataParams,
|
||||
LineSeriesOption,
|
||||
TopLevelFormatterParams,
|
||||
@@ -36,6 +38,7 @@ import { formatTime } from "../../../../../common/datetime/format_time";
|
||||
import type { HaECOption } from "../../../../../resources/echarts/echarts";
|
||||
import type { StatisticPeriod } from "../../../../../data/recorder";
|
||||
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
|
||||
import { createYAxisPrecisionBounds } from "../../../../../components/chart/y-axis-fraction-digits";
|
||||
import "../../../../../components/chart/ha-chart-tooltip-marker";
|
||||
import { getSuggestedPeriod } from "../../../../../data/energy";
|
||||
|
||||
@@ -95,13 +98,15 @@ export function getSuggestedMax(
|
||||
|
||||
function createYAxisLabelFormatter(
|
||||
locale: FrontendLocaleData,
|
||||
fractionDigits: number
|
||||
getFractionDigits: () => number
|
||||
) {
|
||||
return (value: number): string =>
|
||||
formatNumber(value, locale, {
|
||||
return (value: number): string => {
|
||||
const fractionDigits = getFractionDigits();
|
||||
return formatNumber(value, locale, {
|
||||
minimumFractionDigits: value === 0 ? 0 : fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommonOptions(
|
||||
@@ -123,6 +128,11 @@ export function getCommonOptions(
|
||||
const showCompareYear =
|
||||
compare && start.getFullYear() !== compareStart.getFullYear();
|
||||
|
||||
// Recompute the tick-label precision from the visible axis extent so labels
|
||||
// stay distinct when zooming in on a narrow range. Energy axes are anchored
|
||||
// at 0, so the extent is unioned with 0 to match the rendered ticks.
|
||||
let currentFractionDigits = yAxisFractionDigits;
|
||||
|
||||
// Extend suggestedMax so compare bars that land past the main end
|
||||
// (e.g. Feb compared to Jan) stay visible instead of being clipped.
|
||||
if (compare) {
|
||||
@@ -168,8 +178,17 @@ export function getCommonOptions(
|
||||
nameTextStyle: {
|
||||
align: "left",
|
||||
},
|
||||
...createYAxisPrecisionBounds({
|
||||
includeZero: true,
|
||||
onFractionDigits: (digits) => {
|
||||
currentFractionDigits = digits;
|
||||
},
|
||||
}),
|
||||
axisLabel: {
|
||||
formatter: createYAxisLabelFormatter(locale, yAxisFractionDigits),
|
||||
formatter: createYAxisLabelFormatter(
|
||||
locale,
|
||||
() => currentFractionDigits
|
||||
),
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
@@ -377,12 +396,104 @@ const PERIOD_MS: Record<string, number> = {
|
||||
/**
|
||||
* Offset from a period's start to its midpoint, for centering sub-daily bars
|
||||
* (and forecast lines) between axis ticks — 0 for daily+ periods, which sit at
|
||||
* the start. Derived from the period, not from the data, so the first/only
|
||||
* bucket centers identically to every other bucket. (Previously estimated from
|
||||
* the gap between the first two entries, which collapsed to 0 with one bucket.)
|
||||
* the start.
|
||||
*
|
||||
* `measuredGap` is the gap between the first two entries, when available. It
|
||||
* adapts the offset to data that is finer-grained than the nominal period
|
||||
* (e.g. external forecast data), but is clamped to the nominal period so
|
||||
* sparse data (gaps between readings) can't inflate the offset, and a lone
|
||||
* bucket (no gap to measure) still centers on the nominal midpoint.
|
||||
*/
|
||||
export function getPeriodMidpointOffset(period: string): number {
|
||||
return (PERIOD_MS[period] ?? 0) / 2;
|
||||
export function getPeriodMidpointOffset(
|
||||
period: string,
|
||||
measuredGap?: number
|
||||
): number {
|
||||
const nominal = PERIOD_MS[period] ?? 0;
|
||||
return (measuredGap ? Math.min(measuredGap, nominal) : nominal) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the expected statistics-bucket grid across [start, end) so sparse
|
||||
* data can be zero-filled. Without a dense grid, ECharts derives the bar band
|
||||
* width from the minimum gap between data points: sparse data yields
|
||||
* oversized bars, and a single point makes ECharts expand the time axis by
|
||||
* ±40% of its span, ignoring the configured min/max.
|
||||
*
|
||||
* The grid is anchored on a real data bucket rather than on `start`: recorder
|
||||
* buckets are UTC-aligned, so in half-hour timezones they don't sit on local
|
||||
* period boundaries. Stepping from a real bucket keeps generated buckets
|
||||
* exactly on the data's grid (midpoints for sub-daily periods, period starts
|
||||
* otherwise). A non-compare series is preferred so the grid aligns with the
|
||||
* main data; a compare series is only used as a fallback so a compare-only
|
||||
* view — e.g. today has no data yet but the compare period does — still gets
|
||||
* zero-filled instead of leaving a lone bar that expands the axis. Returns an
|
||||
* empty array when there is no data to anchor on.
|
||||
*/
|
||||
export function generateFillBuckets(
|
||||
datasets: BarSeriesOption[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
): number[] {
|
||||
const firstBucketX = (includeCompare: boolean): number | undefined => {
|
||||
for (const dataset of datasets) {
|
||||
if (
|
||||
dataset.type !== "bar" ||
|
||||
!dataset.data?.length ||
|
||||
(!includeCompare && String(dataset.id).startsWith("compare-"))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const first = dataset.data[0];
|
||||
const value =
|
||||
first && typeof first === "object" && "value" in first
|
||||
? first.value
|
||||
: first;
|
||||
const x = Number((value as number[])?.[0]);
|
||||
if (!Number.isNaN(x)) {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Prefer a non-compare anchor; fall back to any bar series (compare included).
|
||||
const anchor = firstBucketX(false) ?? firstBucketX(true);
|
||||
if (anchor === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const anchorDate = new Date(anchor);
|
||||
// Step relative to the anchor (not iteratively) so month-length clamping
|
||||
// and DST shifts can't accumulate drift.
|
||||
const bucketAt = (n: number): number =>
|
||||
(period === "5minute"
|
||||
? addMinutes(anchorDate, 5 * n)
|
||||
: period === "hour"
|
||||
? addHours(anchorDate, n)
|
||||
: period === "day"
|
||||
? addDays(anchorDate, n)
|
||||
: addMonths(anchorDate, n)
|
||||
).getTime();
|
||||
|
||||
const startMs = start.getTime();
|
||||
const endMs = end.getTime();
|
||||
const buckets: number[] = [];
|
||||
for (let n = 0; ; n--) {
|
||||
const ts = bucketAt(n);
|
||||
if (ts < startMs) {
|
||||
break;
|
||||
}
|
||||
buckets.push(ts);
|
||||
}
|
||||
for (let n = 1; ; n++) {
|
||||
const ts = bucketAt(n);
|
||||
if (ts >= endMs) {
|
||||
break;
|
||||
}
|
||||
buckets.push(ts);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export interface UntrackedSplit {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
computeStatMidpoint,
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
splitUntrackedConsumption,
|
||||
@@ -238,15 +239,15 @@ function processUntracked(
|
||||
const sortedTimes = Object.keys(consumptionData.used_total).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
// Only start timestamps available here, so center sub-daily bars using the
|
||||
// gap between the first two entries. With a lone first-of-day bucket there is
|
||||
// no gap to measure, so fall back to the nominal period midpoint — which
|
||||
// matches the device bars' computeStatMidpoint instead of collapsing to the
|
||||
// period start and splitting into a second stack.
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
|
||||
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
|
||||
: getPeriodMidpointOffset(period);
|
||||
// Only start timestamps available here, so center sub-daily bars from the
|
||||
// gap between the first two entries, clamped to the nominal period so
|
||||
// sparse or lone buckets stay centered on the same grid as the device bars.
|
||||
const periodOffset = getPeriodMidpointOffset(
|
||||
period,
|
||||
sortedTimes.length >= 2
|
||||
? Number(sortedTimes[1]) - Number(sortedTimes[0])
|
||||
: undefined
|
||||
);
|
||||
sortedTimes.forEach((time) => {
|
||||
const ts = Number(time);
|
||||
const x = compare
|
||||
@@ -515,7 +516,11 @@ export function generateEnergyDevicesDetailGraphData(
|
||||
}
|
||||
}
|
||||
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
|
||||
);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { getEnergyColor } from "./common/color";
|
||||
import {
|
||||
type EnergyDataPoint,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
} from "./common/energy-chart-options";
|
||||
|
||||
@@ -113,7 +114,11 @@ export function generateEnergyGasGraphData(
|
||||
)
|
||||
);
|
||||
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(datasets, start, end, period)
|
||||
);
|
||||
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
const chartData = datasets;
|
||||
const total = processTotal(energyData.stats, gasSources);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getEnergyColor } from "./common/color";
|
||||
import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
} from "./common/energy-chart-options";
|
||||
@@ -117,7 +118,11 @@ export function generateEnergySolarGraphData(
|
||||
)
|
||||
);
|
||||
|
||||
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets as BarSeriesOption[],
|
||||
true,
|
||||
generateFillBuckets(datasets as BarSeriesOption[], start, end, period)
|
||||
);
|
||||
|
||||
if (forecasts) {
|
||||
datasets.push(
|
||||
@@ -322,20 +327,18 @@ function processForecast(
|
||||
|
||||
if (forecastsData) {
|
||||
const solarForecastData: LineSeriesOption["data"] = [];
|
||||
// Only center forecast points for sub-daily periods to align with bars.
|
||||
// Only start timestamps available, so estimate midpoint from the gap
|
||||
// between the first two entries; with a lone first bucket there is no
|
||||
// gap to measure, so fall back to the nominal period midpoint.
|
||||
let forecastOffset = 0;
|
||||
if (period === "hour" || period === "5minute") {
|
||||
const forecastTimes = Object.keys(forecastsData)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
forecastOffset =
|
||||
forecastTimes.length >= 2
|
||||
? (forecastTimes[1] - forecastTimes[0]) / 2
|
||||
: getPeriodMidpointOffset(period);
|
||||
}
|
||||
// Center forecast points for sub-daily periods from the gap between
|
||||
// the first two entries, clamped to the nominal period so sparse or
|
||||
// lone forecast buckets still align with the bars.
|
||||
const forecastTimes = Object.keys(forecastsData)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const forecastOffset = getPeriodMidpointOffset(
|
||||
period,
|
||||
forecastTimes.length >= 2
|
||||
? forecastTimes[1] - forecastTimes[0]
|
||||
: undefined
|
||||
);
|
||||
for (const [time, value] of Object.entries(forecastsData)) {
|
||||
const kWh = value / 1000;
|
||||
solarForecastData.push([Number(time) + forecastOffset, kWh]);
|
||||
|
||||
@@ -37,6 +37,7 @@ import { hasConfigChanged } from "../../common/has-changed";
|
||||
import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCommonOptions,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
@@ -450,7 +451,16 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
// @ts-expect-error
|
||||
datasets.sort((a, b) => a.order - b.order);
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(
|
||||
datasets,
|
||||
this._start,
|
||||
this._end,
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._legendData = this._getLegendData(datasets);
|
||||
@@ -603,15 +613,14 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
const uniqueKeys = summedData.timestamps;
|
||||
|
||||
// Only center bars for sub-daily periods (hour/5min). Only start timestamps
|
||||
// available here, so estimate midpoint from the gap between the first two
|
||||
// entries; with a lone first-of-day bucket there is no gap to measure, so
|
||||
// fall back to the nominal period midpoint so the bar stays centered.
|
||||
// Only start timestamps available here, so center sub-daily bars from the
|
||||
// gap between the first two entries, clamped to the nominal period so
|
||||
// sparse or lone buckets stay centered on the same grid as dense data.
|
||||
const period = getSuggestedPeriod(this._start, this._end);
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
|
||||
? (uniqueKeys[1] - uniqueKeys[0]) / 2
|
||||
: getPeriodMidpointOffset(period);
|
||||
const periodOffset = getPeriodMidpointOffset(
|
||||
period,
|
||||
uniqueKeys.length >= 2 ? uniqueKeys[1] - uniqueKeys[0] : undefined
|
||||
);
|
||||
|
||||
const compareTransform = getCompareTransform(
|
||||
this._start,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
computeStatMidpoint,
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
generateFillBuckets,
|
||||
getCommonOptions,
|
||||
getCompareTransform,
|
||||
} from "./common/energy-chart-options";
|
||||
@@ -263,7 +264,16 @@ export class HuiEnergyWaterGraphCard
|
||||
)
|
||||
);
|
||||
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
fillDataGapsAndRoundCaps(
|
||||
datasets,
|
||||
true,
|
||||
generateFillBuckets(
|
||||
datasets,
|
||||
this._start,
|
||||
this._end,
|
||||
getSuggestedPeriod(this._start, this._end)
|
||||
)
|
||||
);
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._total = this._processTotal(energyData.stats, waterSources);
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import "../../../components/ha-card";
|
||||
import type { ImageEntity } from "../../../data/image";
|
||||
import { computeImageUrl } from "../../../data/image";
|
||||
import type {
|
||||
ActionHandlerOptions,
|
||||
ActionHandlerResolution,
|
||||
} from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import type { LovelaceElement, LovelaceElementConfig } from "../elements/types";
|
||||
import type { LovelaceCard, LovelaceCardEditor } from "../types";
|
||||
import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element";
|
||||
import type { HitTarget } from "./picture-elements/nearest-hit";
|
||||
import { pickNearestTarget } from "./picture-elements/nearest-hit";
|
||||
import {
|
||||
PREVIEW_CLICK_CALLBACK,
|
||||
type PictureElementsCardConfig,
|
||||
} from "./types";
|
||||
import type { PersonEntity } from "../../../data/person";
|
||||
|
||||
// Point/text elements whose pointer gestures are routed to the nearest target
|
||||
// so that dead gaps between elements become tappable and overlapping hit areas
|
||||
// no longer steal each other's taps. These elements delegate their pointer
|
||||
// handling to the card (keeping keyboard activation for themselves) and expose
|
||||
// their visible hit target via getHitInfo(); the card binds the shared
|
||||
// action-handler on #root with a resolver, so routed gestures run on the same
|
||||
// engine (hold ripple, cancellation, timers) as every other card.
|
||||
// How far (px) a tap may sit from an icon seed and still be routed to it.
|
||||
const NEAREST_HIT_REACH = 24;
|
||||
|
||||
// A routed target: `x1..x2 @ cy` is the seed used for the nearest test (icons
|
||||
// are a point, labels the horizontal text segment); `bx/by/bw/bh` is the
|
||||
// element's visible box, used to decide whether a tap lands directly on it.
|
||||
interface NearestSeed extends HitTarget {
|
||||
element: LovelaceElement;
|
||||
options: ActionHandlerOptions;
|
||||
}
|
||||
|
||||
@customElement("hui-picture-elements-card")
|
||||
class HuiPictureElementsCard extends LitElement implements LovelaceCard {
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
@@ -30,6 +55,8 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _elements?: LovelaceElement[];
|
||||
|
||||
@query("#root") private _root?: HTMLElement;
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
@@ -93,6 +120,7 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (!this._config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -123,6 +151,97 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a pointer gesture on #root to the routed element it belongs to.
|
||||
// Geometry is read fresh from the rendered DOM at every gesture, so it can
|
||||
// never go stale; the couple of rect reads per press are cheap.
|
||||
private _resolveGesture = (
|
||||
x: number,
|
||||
y: number,
|
||||
ev: Event
|
||||
): ActionHandlerResolution | null => {
|
||||
const root = this._root;
|
||||
// In the editor preview, clicks set element positions; don't route them.
|
||||
if (!root || this.preview) {
|
||||
return null;
|
||||
}
|
||||
// A non-primary or ctrl mouse press produces no click to complete a
|
||||
// gesture and would leave the engine armed.
|
||||
if (
|
||||
ev.type === "mousedown" &&
|
||||
((ev as MouseEvent).button !== 0 || (ev as MouseEvent).ctrlKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// A press that an interactive non-routed element (a button, custom card,
|
||||
// image element, or conditional child) catches natively is that element's
|
||||
// own gesture; only presses on routed elements and the background route.
|
||||
for (const node of ev.composedPath()) {
|
||||
if (node === root) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.classList.contains("element") &&
|
||||
!(node as LovelaceElement).delegatedActions
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const seeds = this._collectSeeds(rootRect);
|
||||
if (!seeds.length) {
|
||||
return null;
|
||||
}
|
||||
const seed = pickNearestTarget(
|
||||
seeds,
|
||||
x - rootRect.left,
|
||||
y - rootRect.top,
|
||||
NEAREST_HIT_REACH
|
||||
);
|
||||
return seed ? { target: seed.element, options: seed.options } : null;
|
||||
};
|
||||
|
||||
private _collectSeeds(rootRect: DOMRect): NearestSeed[] {
|
||||
const seeds: NearestSeed[] = [];
|
||||
const root = this._root;
|
||||
if (!root) {
|
||||
return seeds;
|
||||
}
|
||||
// Routed targets can be nested (e.g. inside hui-conditional-element), so
|
||||
// walk the rendered subtree rather than only the top-level elements.
|
||||
for (const element of root.querySelectorAll<LovelaceElement>("*")) {
|
||||
if (!element.delegatedActions || !element.getHitInfo) {
|
||||
continue;
|
||||
}
|
||||
// A hidden element (display: none, visibility: hidden, …) must not
|
||||
// become an invisible tap target.
|
||||
if (element.checkVisibility && !element.checkVisibility()) {
|
||||
continue;
|
||||
}
|
||||
const hit = element.getHitInfo();
|
||||
if (!hit || hit.rect.width <= 0 || hit.rect.height <= 0) {
|
||||
continue;
|
||||
}
|
||||
const bx = hit.rect.left - rootRect.left;
|
||||
const by = hit.rect.top - rootRect.top;
|
||||
const isIcon = !hit.isText;
|
||||
const cx = bx + hit.rect.width / 2;
|
||||
seeds.push({
|
||||
element,
|
||||
options: hit.options,
|
||||
isIcon,
|
||||
x1: isIcon ? cx : bx,
|
||||
x2: isIcon ? cx : bx + hit.rect.width,
|
||||
cy: by + hit.rect.height / 2,
|
||||
bx,
|
||||
by,
|
||||
bw: hit.rect.width,
|
||||
bh: hit.rect.height,
|
||||
});
|
||||
}
|
||||
return seeds;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
@@ -156,7 +275,10 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card .header=${this._config.title}>
|
||||
<div id="root">
|
||||
<div
|
||||
id="root"
|
||||
.actionHandler=${actionHandler({ resolve: this._resolveGesture })}
|
||||
>
|
||||
<hui-image
|
||||
.hass=${this.hass}
|
||||
.image=${image}
|
||||
|
||||
@@ -516,12 +516,16 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _isForecastInteraction(ev: Event): boolean {
|
||||
return ev
|
||||
.composedPath()
|
||||
.some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement && node.classList.contains("forecast")
|
||||
);
|
||||
return (
|
||||
(this._dragScrollController.scrolled ||
|
||||
this._dragScrollController.scrolling) &&
|
||||
ev
|
||||
.composedPath()
|
||||
.some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement && node.classList.contains("forecast")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _groupForecastByDay(forecast: ForecastAttribute[]) {
|
||||
|
||||
@@ -4,6 +4,18 @@ import type {
|
||||
LovelaceElementConfig,
|
||||
} from "../../elements/types";
|
||||
|
||||
// Element types whose taps the picture-elements card routes to the nearest
|
||||
// icon/label target. They delegate pointer handling to the card's container
|
||||
// gesture (keeping keyboard activation for themselves). Marking them here, at
|
||||
// creation, ensures the routing also covers elements created inside wrappers
|
||||
// such as hui-conditional-element (which builds its children via this factory).
|
||||
export const NEAREST_ROUTED_TYPES = new Set([
|
||||
"state-icon",
|
||||
"state-badge",
|
||||
"icon",
|
||||
"state-label",
|
||||
]);
|
||||
|
||||
export function createStyledHuiElement(
|
||||
elementConfig: LovelaceElementConfig
|
||||
): LovelaceElement {
|
||||
@@ -13,6 +25,10 @@ export function createStyledHuiElement(
|
||||
element.classList.add("element");
|
||||
}
|
||||
|
||||
if (NEAREST_ROUTED_TYPES.has(elementConfig.type)) {
|
||||
element.delegatedActions = true;
|
||||
}
|
||||
|
||||
if (elementConfig.style) {
|
||||
Object.keys(elementConfig.style).forEach((prop) => {
|
||||
element.style.setProperty(prop, elementConfig.style![prop]);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Pure geometry for routing a tap to the nearest picture-elements target.
|
||||
// Kept free of the DOM so it can be unit-tested and swept in isolation; the card
|
||||
// feeds it seeds already resolved to root-relative coordinates.
|
||||
|
||||
export interface HitTarget {
|
||||
// Icons are a point (x1 === x2); labels are the horizontal text segment x1..x2
|
||||
// at the vertical center line cy.
|
||||
isIcon: boolean;
|
||||
x1: number;
|
||||
x2: number;
|
||||
cy: number;
|
||||
// The element's current clickable box, used for the direct-hit membership test.
|
||||
bx: number;
|
||||
by: number;
|
||||
bw: number;
|
||||
bh: number;
|
||||
}
|
||||
|
||||
// Squared distance from (x, y) to the seed segment (dx clamped to the segment,
|
||||
// dy to the center line). Squared to avoid a sqrt in the hot path.
|
||||
export const seedDistanceSquared = (
|
||||
seed: HitTarget,
|
||||
x: number,
|
||||
y: number
|
||||
): number => {
|
||||
const dx = Math.max(seed.x1 - x, x - seed.x2, 0);
|
||||
const dy = y - seed.cy;
|
||||
return dx * dx + dy * dy;
|
||||
};
|
||||
|
||||
export const isInsideBox = (seed: HitTarget, x: number, y: number): boolean =>
|
||||
x >= seed.bx &&
|
||||
x <= seed.bx + seed.bw &&
|
||||
y >= seed.by &&
|
||||
y <= seed.by + seed.bh;
|
||||
|
||||
// Pick the target a tap at (x, y) belongs to. A tap on a label's own text always
|
||||
// keeps that label; otherwise icons take priority:
|
||||
// 1. a tap inside a label's (text) box keeps that label,
|
||||
// 2. otherwise a tap inside an icon's box wins (this also covers the box
|
||||
// corners, which lie past point reach),
|
||||
// 3. otherwise the nearest icon within `reach` px wins the open space,
|
||||
// 4. otherwise the nearest label within `reach` px.
|
||||
// Every element box stays clickable; undefined means the tap is outside every
|
||||
// box and beyond reach of every seed (it belongs to the background/image).
|
||||
export const pickNearestTarget = <T extends HitTarget>(
|
||||
seeds: readonly T[],
|
||||
x: number,
|
||||
y: number,
|
||||
reach: number
|
||||
): T | undefined => {
|
||||
const reachSquared = reach * reach;
|
||||
let directIcon: T | undefined;
|
||||
let directLabel: T | undefined;
|
||||
let nearestIcon: T | undefined;
|
||||
let nearestLabel: T | undefined;
|
||||
let directIconDist = Infinity;
|
||||
let directLabelDist = Infinity;
|
||||
let iconDist = reachSquared;
|
||||
let labelDist = reachSquared;
|
||||
for (const seed of seeds) {
|
||||
const dist = seedDistanceSquared(seed, x, y);
|
||||
const inside = isInsideBox(seed, x, y);
|
||||
if (seed.isIcon) {
|
||||
if (inside && dist < directIconDist) {
|
||||
directIconDist = dist;
|
||||
directIcon = seed;
|
||||
}
|
||||
if (dist < iconDist) {
|
||||
iconDist = dist;
|
||||
nearestIcon = seed;
|
||||
}
|
||||
} else {
|
||||
if (inside && dist < directLabelDist) {
|
||||
directLabelDist = dist;
|
||||
directLabel = seed;
|
||||
}
|
||||
if (dist < labelDist) {
|
||||
labelDist = dist;
|
||||
nearestLabel = seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return directLabel ?? directIcon ?? nearestIcon ?? nearestLabel;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
import type {
|
||||
ActionHandlerDetail,
|
||||
ActionHandlerOptions,
|
||||
ActionHandlerResolution,
|
||||
} from "../../../../data/lovelace/action_handler";
|
||||
import { isTouch } from "../../../../util/is_touch";
|
||||
|
||||
@@ -33,6 +34,21 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const DOUBLE_CLICK_TIME = 250;
|
||||
|
||||
// The coordinates of the pointer that changed: for touch events the finger
|
||||
// that just went down/up (touches[0] would be the *first* finger during a
|
||||
// multi-touch), for mouse events the pointer itself.
|
||||
const eventCoordinates = (ev: Event): { x: number; y: number } => {
|
||||
const touch = (ev as TouchEvent).changedTouches?.[0];
|
||||
return touch
|
||||
? { x: touch.clientX, y: touch.clientY }
|
||||
: { x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY };
|
||||
};
|
||||
|
||||
const activeTouchCount = (ev: Event): number =>
|
||||
(ev as TouchEvent).touches?.length ?? 0;
|
||||
|
||||
@customElement("action-handler")
|
||||
class ActionHandler extends HTMLElement implements ActionHandlerType {
|
||||
public holdTime = 500;
|
||||
@@ -45,6 +61,14 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
|
||||
|
||||
private dblClickTimeout?: number;
|
||||
|
||||
// The double-tap window only pairs two taps on the same target; a quick tap
|
||||
// on a different target starts its own window instead of completing one.
|
||||
private dblClickTarget?: HTMLElement;
|
||||
|
||||
// The delegated target of the gesture in flight on a container binding
|
||||
// (options.resolve); undefined while no resolved gesture is active.
|
||||
private resolved?: ActionHandlerResolution;
|
||||
|
||||
// eslint-disable-next-line lit/lifecycle-super -- not a LitElement
|
||||
public connectedCallback() {
|
||||
Object.assign(this.style, {
|
||||
@@ -108,6 +132,16 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
|
||||
"keydown",
|
||||
element.actionHandler.handleKeyDown!
|
||||
);
|
||||
} else if (options.resolve) {
|
||||
// A container binding suppresses the context menu only while it owns a
|
||||
// gesture (a touch long-press); a plain right-click keeps its menu.
|
||||
element.addEventListener("contextmenu", (ev: Event) => {
|
||||
if (!this.resolved) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
} else {
|
||||
element.addEventListener("contextmenu", (ev: Event) => {
|
||||
const e = ev || window.event;
|
||||
@@ -131,17 +165,30 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
|
||||
|
||||
element.actionHandler.start = (ev: Event) => {
|
||||
this.cancelled = false;
|
||||
let x;
|
||||
let y;
|
||||
if ((ev as TouchEvent).touches) {
|
||||
x = (ev as TouchEvent).touches[0].clientX;
|
||||
y = (ev as TouchEvent).touches[0].clientY;
|
||||
const { x, y } = eventCoordinates(ev);
|
||||
|
||||
if (options.resolve) {
|
||||
this.resolved = undefined;
|
||||
// More than one finger is a pinch or scroll, not a gesture.
|
||||
if (activeTouchCount(ev) > 1) {
|
||||
if (this.timer) {
|
||||
this._stopAnimation();
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolution = options.resolve(x, y, ev);
|
||||
if (!resolution) {
|
||||
return;
|
||||
}
|
||||
this.resolved = resolution;
|
||||
} else {
|
||||
x = (ev as MouseEvent).clientX;
|
||||
y = (ev as MouseEvent).clientY;
|
||||
this.resolved = undefined;
|
||||
}
|
||||
|
||||
if (options.hasHold) {
|
||||
const opts = options.resolve ? this.resolved!.options : options;
|
||||
if (opts.hasHold) {
|
||||
this.held = false;
|
||||
this.timer = window.setTimeout(() => {
|
||||
this._startAnimation(x, y);
|
||||
@@ -156,37 +203,87 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
|
||||
ev.type === "touchcancel" ||
|
||||
(ev.type === "touchend" && this.cancelled)
|
||||
) {
|
||||
if (options.resolve) {
|
||||
this.resolved = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const target = ev.target as HTMLElement;
|
||||
|
||||
let target = ev.target as HTMLElement;
|
||||
let opts = options;
|
||||
if (options.resolve) {
|
||||
const resolved = this.resolved;
|
||||
// Only handle gestures the resolver claimed at their start (not a
|
||||
// click bubbling from an interactive child or keyboard activation).
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
// An end while other fingers remain belongs to a pinch or scroll:
|
||||
// drop the claimed gesture so lifting the last finger cannot fire a
|
||||
// stray tap from what was a multi-touch gesture.
|
||||
if (activeTouchCount(ev) > 0) {
|
||||
this.resolved = undefined;
|
||||
if (this.timer) {
|
||||
this._stopAnimation();
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.resolved = undefined;
|
||||
// The release must still resolve to the same target, so dragging
|
||||
// away from the press target aborts, as it does on a per-element
|
||||
// binding where the click never reaches the element.
|
||||
const { x, y } = eventCoordinates(ev);
|
||||
if (options.resolve(x, y, ev)?.target !== resolved.target) {
|
||||
if (this.timer) {
|
||||
this._stopAnimation();
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
target = resolved.target;
|
||||
opts = resolved.options;
|
||||
}
|
||||
|
||||
// Prevent mouse event if touch event
|
||||
if (ev.cancelable) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
if (options.hasHold) {
|
||||
if (opts.hasHold) {
|
||||
clearTimeout(this.timer);
|
||||
this._stopAnimation();
|
||||
this.timer = undefined;
|
||||
}
|
||||
if (options.hasHold && this.held) {
|
||||
if (opts.hasHold && this.held) {
|
||||
fireEvent(target, "action", { action: "hold" });
|
||||
} else if (options.hasDoubleClick) {
|
||||
} else if (opts.hasDoubleClick) {
|
||||
if (
|
||||
(ev.type === "click" && (ev as MouseEvent).detail < 2) ||
|
||||
!this.dblClickTimeout
|
||||
!this.dblClickTimeout ||
|
||||
this.dblClickTarget !== target
|
||||
) {
|
||||
this.dblClickTimeout = window.setTimeout(() => {
|
||||
this.dblClickTimeout = undefined;
|
||||
if (options.hasTap !== false) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
// Only clear the shared pending state if a later tap has not
|
||||
// re-armed it for another target.
|
||||
if (this.dblClickTimeout === timeoutId) {
|
||||
this.dblClickTimeout = undefined;
|
||||
this.dblClickTarget = undefined;
|
||||
}
|
||||
if (opts.hasTap !== false) {
|
||||
fireEvent(target, "action", { action: "tap" });
|
||||
}
|
||||
}, 250);
|
||||
}, DOUBLE_CLICK_TIME);
|
||||
this.dblClickTimeout = timeoutId;
|
||||
this.dblClickTarget = target;
|
||||
} else {
|
||||
clearTimeout(this.dblClickTimeout);
|
||||
this.dblClickTimeout = undefined;
|
||||
this.dblClickTarget = undefined;
|
||||
fireEvent(target, "action", { action: "double_tap" });
|
||||
}
|
||||
} else if (options.hasTap !== false) {
|
||||
} else if (opts.hasTap !== false) {
|
||||
fireEvent(target, "action", { action: "tap" });
|
||||
}
|
||||
};
|
||||
@@ -198,16 +295,18 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
|
||||
(ev.currentTarget as ActionHandlerElement).actionHandler!.end!(ev);
|
||||
};
|
||||
|
||||
element.addEventListener("touchstart", element.actionHandler.start, {
|
||||
passive: true,
|
||||
});
|
||||
element.addEventListener("touchend", element.actionHandler.end);
|
||||
element.addEventListener("touchcancel", element.actionHandler.end);
|
||||
if (!options.keyboardOnly) {
|
||||
element.addEventListener("touchstart", element.actionHandler.start, {
|
||||
passive: true,
|
||||
});
|
||||
element.addEventListener("touchend", element.actionHandler.end);
|
||||
element.addEventListener("touchcancel", element.actionHandler.end);
|
||||
|
||||
element.addEventListener("mousedown", element.actionHandler.start, {
|
||||
passive: true,
|
||||
});
|
||||
element.addEventListener("click", element.actionHandler.end);
|
||||
element.addEventListener("mousedown", element.actionHandler.start, {
|
||||
passive: true,
|
||||
});
|
||||
element.addEventListener("click", element.actionHandler.end);
|
||||
}
|
||||
|
||||
element.addEventListener("keydown", element.actionHandler.handleKeyDown);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import type { LovelacePictureElementEditor } from "../types";
|
||||
import type { IconElementConfig, LovelaceElement } from "./types";
|
||||
import type {
|
||||
IconElementConfig,
|
||||
LovelaceElement,
|
||||
LovelaceElementHitInfo,
|
||||
} from "./types";
|
||||
|
||||
@customElement("hui-icon-element")
|
||||
export class HuiIconElement extends LitElement implements LovelaceElement {
|
||||
@@ -24,8 +28,21 @@ export class HuiIconElement extends LitElement implements LovelaceElement {
|
||||
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
// Pointer gestures are delegated to the picture-elements card's routing;
|
||||
// this element keeps keyboard activation (see LovelaceElement).
|
||||
public delegatedActions = false;
|
||||
|
||||
@state() private _config?: IconElementConfig;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
// Listen on the host so both the own (keyboard) gesture path and an
|
||||
// action delegated by the picture-elements card land here.
|
||||
this.addEventListener("action", (ev) =>
|
||||
this._handleAction(ev as ActionHandlerEvent)
|
||||
);
|
||||
}
|
||||
|
||||
public setConfig(config: IconElementConfig): void {
|
||||
if (!config.icon) {
|
||||
throw Error("Icon required");
|
||||
@@ -38,6 +55,21 @@ export class HuiIconElement extends LitElement implements LovelaceElement {
|
||||
};
|
||||
}
|
||||
|
||||
public getHitInfo(): LovelaceElementHitInfo | null {
|
||||
if (!this._config) {
|
||||
return null;
|
||||
}
|
||||
const options = {
|
||||
hasTap: hasAction(this._config.tap_action),
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
};
|
||||
if (!options.hasTap && !options.hasHold && !options.hasDoubleClick) {
|
||||
return null;
|
||||
}
|
||||
return { rect: this.getBoundingClientRect(), options };
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass) {
|
||||
return nothing;
|
||||
@@ -47,10 +79,10 @@ export class HuiIconElement extends LitElement implements LovelaceElement {
|
||||
<ha-icon
|
||||
.icon=${this._config.icon}
|
||||
.title=${computeTooltip(this.hass, this._config)}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.double_tap_action),
|
||||
keyboardOnly: this.delegatedActions || undefined,
|
||||
})}
|
||||
tabindex=${ifDefined(
|
||||
hasAction(this._config.tap_action) ? "0" : undefined
|
||||
@@ -67,6 +99,11 @@ export class HuiIconElement extends LitElement implements LovelaceElement {
|
||||
:host {
|
||||
cursor: pointer;
|
||||
}
|
||||
ha-icon {
|
||||
/* Size the box to the glyph (not the taller inline line box that leaves
|
||||
the icon edges unclickable) while staying inline-level. */
|
||||
display: inline-flex;
|
||||
}
|
||||
ha-icon:focus {
|
||||
outline: none;
|
||||
background: var(--divider-color);
|
||||
|
||||
@@ -16,7 +16,11 @@ import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import "../components/hui-warning-element";
|
||||
import type { LovelacePictureElementEditor } from "../types";
|
||||
import type { LovelaceElement, StateBadgeElementConfig } from "./types";
|
||||
import type {
|
||||
LovelaceElement,
|
||||
LovelaceElementHitInfo,
|
||||
StateBadgeElementConfig,
|
||||
} from "./types";
|
||||
|
||||
@customElement("hui-state-badge-element")
|
||||
export class HuiStateBadgeElement
|
||||
@@ -51,8 +55,36 @@ export class HuiStateBadgeElement
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
// Pointer gestures are delegated to the picture-elements card's routing;
|
||||
// this element keeps keyboard activation (see LovelaceElement).
|
||||
public delegatedActions = false;
|
||||
|
||||
@state() private _config?: StateBadgeElementConfig;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
// Listen on the host so both the own (keyboard) gesture path and an
|
||||
// action delegated by the picture-elements card land here.
|
||||
this.addEventListener("action", (ev) =>
|
||||
this._handleAction(ev as ActionHandlerEvent)
|
||||
);
|
||||
}
|
||||
|
||||
public getHitInfo(): LovelaceElementHitInfo | null {
|
||||
if (!this._config || !this.hass?.states[this._config.entity!]) {
|
||||
return null;
|
||||
}
|
||||
const options = {
|
||||
hasTap: hasAction(this._config.tap_action),
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
};
|
||||
if (!options.hasTap && !options.hasHold && !options.hasDoubleClick) {
|
||||
return null;
|
||||
}
|
||||
return { rect: this.getBoundingClientRect(), options };
|
||||
}
|
||||
|
||||
public setConfig(config: StateBadgeElementConfig): void {
|
||||
if (!config.entity) {
|
||||
throw Error("Entity required");
|
||||
@@ -102,10 +134,10 @@ export class HuiStateBadgeElement
|
||||
: this._config.title
|
||||
}
|
||||
show-name
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.double_tap_action),
|
||||
keyboardOnly: this.delegatedActions || undefined,
|
||||
})}
|
||||
tabindex=${ifDefined(
|
||||
hasAction(this._config.tap_action) ? "0" : undefined
|
||||
|
||||
@@ -16,7 +16,11 @@ import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import "../components/hui-warning-element";
|
||||
import type { LovelacePictureElementEditor } from "../types";
|
||||
import type { LovelaceElement, StateIconElementConfig } from "./types";
|
||||
import type {
|
||||
LovelaceElement,
|
||||
LovelaceElementHitInfo,
|
||||
StateIconElementConfig,
|
||||
} from "./types";
|
||||
|
||||
@customElement("hui-state-icon-element")
|
||||
export class HuiStateIconElement extends LitElement implements LovelaceElement {
|
||||
@@ -48,8 +52,36 @@ export class HuiStateIconElement extends LitElement implements LovelaceElement {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
// Pointer gestures are delegated to the picture-elements card's routing;
|
||||
// this element keeps keyboard activation (see LovelaceElement).
|
||||
public delegatedActions = false;
|
||||
|
||||
@state() private _config?: StateIconElementConfig;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
// Listen on the host so both the own (keyboard) gesture path and an
|
||||
// action delegated by the picture-elements card land here.
|
||||
this.addEventListener("action", (ev) =>
|
||||
this._handleAction(ev as ActionHandlerEvent)
|
||||
);
|
||||
}
|
||||
|
||||
public getHitInfo(): LovelaceElementHitInfo | null {
|
||||
if (!this._config || !this.hass?.states[this._config.entity!]) {
|
||||
return null;
|
||||
}
|
||||
const options = {
|
||||
hasTap: hasAction(this._config.tap_action),
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
};
|
||||
if (!options.hasTap && !options.hasHold && !options.hasDoubleClick) {
|
||||
return null;
|
||||
}
|
||||
return { rect: this.getBoundingClientRect(), options };
|
||||
}
|
||||
|
||||
public setConfig(config: StateIconElementConfig): void {
|
||||
if (!config.entity) {
|
||||
throw Error("Entity required");
|
||||
@@ -86,10 +118,10 @@ export class HuiStateIconElement extends LitElement implements LovelaceElement {
|
||||
<state-badge
|
||||
.stateObj=${stateObj}
|
||||
.title=${computeTooltip(this.hass, this._config)}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.double_tap_action),
|
||||
keyboardOnly: this.delegatedActions || undefined,
|
||||
})}
|
||||
tabindex=${ifDefined(
|
||||
hasAction(this._config.tap_action) ? "0" : undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
@@ -15,7 +15,11 @@ import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import "../components/hui-warning-element";
|
||||
import type { LovelacePictureElementEditor } from "../types";
|
||||
import type { LovelaceElement, StateLabelElementConfig } from "./types";
|
||||
import type {
|
||||
LovelaceElement,
|
||||
LovelaceElementHitInfo,
|
||||
StateLabelElementConfig,
|
||||
} from "./types";
|
||||
|
||||
@customElement("hui-state-label-element")
|
||||
class HuiStateLabelElement extends LitElement implements LovelaceElement {
|
||||
@@ -47,8 +51,23 @@ class HuiStateLabelElement extends LitElement implements LovelaceElement {
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
// Pointer gestures are delegated to the picture-elements card's routing;
|
||||
// this element keeps keyboard activation (see LovelaceElement).
|
||||
public delegatedActions = false;
|
||||
|
||||
@state() private _config?: StateLabelElementConfig;
|
||||
|
||||
@query("div") private _content?: HTMLDivElement;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
// Listen on the host so both the own (keyboard) gesture path and an
|
||||
// action delegated by the picture-elements card land here.
|
||||
this.addEventListener("action", (ev) =>
|
||||
this._handleAction(ev as ActionHandlerEvent)
|
||||
);
|
||||
}
|
||||
|
||||
public setConfig(config: StateLabelElementConfig): void {
|
||||
if (!config.entity) {
|
||||
throw Error("Entity required");
|
||||
@@ -97,10 +116,10 @@ class HuiStateLabelElement extends LitElement implements LovelaceElement {
|
||||
return html`
|
||||
<div
|
||||
.title=${computeTooltip(this.hass, this._config)}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(this._config!.hold_action),
|
||||
hasDoubleClick: hasAction(this._config!.double_tap_action),
|
||||
keyboardOnly: this.delegatedActions || undefined,
|
||||
})}
|
||||
tabindex=${ifDefined(
|
||||
hasAction(this._config.tap_action) ? "0" : undefined
|
||||
@@ -119,6 +138,33 @@ class HuiStateLabelElement extends LitElement implements LovelaceElement {
|
||||
handleAction(this, this.hass!, this._config!, ev.detail.action!);
|
||||
}
|
||||
|
||||
// The visible text's bounds (excluding the padded host box), so routing
|
||||
// only claims the text the label shows; null while a warning is rendered.
|
||||
public getHitInfo(): LovelaceElementHitInfo | null {
|
||||
if (!this._config || !this.hass || !this._content) {
|
||||
return null;
|
||||
}
|
||||
const stateObj = this.hass.states[this._config.entity!];
|
||||
if (
|
||||
!stateObj ||
|
||||
(this._config.attribute &&
|
||||
!(this._config.attribute in stateObj.attributes))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const options = {
|
||||
hasTap: hasAction(this._config.tap_action),
|
||||
hasHold: hasAction(this._config.hold_action),
|
||||
hasDoubleClick: hasAction(this._config.double_tap_action),
|
||||
};
|
||||
if (!options.hasTap && !options.hasHold && !options.hasDoubleClick) {
|
||||
return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this._content);
|
||||
return { rect: range.getBoundingClientRect(), isText: true, options };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { ActionHandlerOptions } from "../../../data/lovelace/action_handler";
|
||||
import type { ActionConfig } from "../../../data/lovelace/config/action";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { Condition } from "../common/validate-condition";
|
||||
@@ -23,6 +24,26 @@ export interface LovelaceElement extends HTMLElement {
|
||||
hass?: HomeAssistant;
|
||||
preview?: boolean;
|
||||
setConfig(config: LovelaceElementConfig): void;
|
||||
/**
|
||||
* Set by the picture-elements card on elements that take part in its
|
||||
* nearest-target tap routing: the card's container gesture handles pointer
|
||||
* input, the element keeps keyboard activation.
|
||||
*/
|
||||
delegatedActions?: boolean;
|
||||
/**
|
||||
* The element's current visible hit target and gesture options, or null
|
||||
* while it has nothing actionable to offer (no actions configured, or an
|
||||
* entity-not-found warning is rendered instead of the element).
|
||||
*/
|
||||
getHitInfo?(): LovelaceElementHitInfo | null;
|
||||
}
|
||||
|
||||
export interface LovelaceElementHitInfo {
|
||||
/** Viewport-relative box of the element's visible hit target. */
|
||||
rect: DOMRect;
|
||||
/** The rect is a text line: route it as a segment, not a point. */
|
||||
isText?: boolean;
|
||||
options: ActionHandlerOptions;
|
||||
}
|
||||
|
||||
export interface ConditionalElementConfig extends LovelaceElementConfigBase {
|
||||
|
||||
@@ -236,7 +236,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
group="section"
|
||||
handle-selector=".handle"
|
||||
draggable-selector=".section"
|
||||
.rollback=${false}
|
||||
>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import {
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../../src/data/context";
|
||||
import {
|
||||
consumeEntityRegistryEntry,
|
||||
consumeEntityState,
|
||||
consumeEntityStates,
|
||||
consumeLocalize,
|
||||
} from "../../../src/common/decorators/consume-context-entry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { HomeAssistantInternationalization } from "../../../src/types";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
|
||||
const makeEntity = (entityId: string, stateValue: string): HassEntity =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
state: stateValue,
|
||||
attributes: {},
|
||||
last_changed: "2024-01-01T00:00:00Z",
|
||||
last_updated: "2024-01-01T00:00:00Z",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
}) as HassEntity;
|
||||
|
||||
const makeRegistryEntry = (
|
||||
entityId: string,
|
||||
name: string
|
||||
): EntityRegistryDisplayEntry => ({
|
||||
entity_id: entityId,
|
||||
name,
|
||||
labels: [],
|
||||
});
|
||||
|
||||
const makeI18n = (localize: LocalizeFunc): HomeAssistantInternationalization =>
|
||||
({ localize }) as HomeAssistantInternationalization;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-consume-entity-state": TestConsumeEntityState;
|
||||
"test-consume-entity-states": TestConsumeEntityStates;
|
||||
"test-consume-registry-entry": TestConsumeRegistryEntry;
|
||||
"test-consume-localize": TestConsumeLocalize;
|
||||
"test-consume-localize-no-state": TestConsumeLocalizeNoState;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test consumers ----------------------------------------------------------
|
||||
|
||||
// Counts update cycles via `updated()` (not `render()`, which lint forbids
|
||||
// assigning to `this` in). One render() runs per update cycle, so this is the
|
||||
// render count. With the fix, an unrelated context change schedules no update,
|
||||
// so this counter does not advance.
|
||||
class RenderCounter extends LitElement {
|
||||
public renderCount = 0;
|
||||
|
||||
protected updated(changed: PropertyValues) {
|
||||
super.updated(changed);
|
||||
this.renderCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-entity-state")
|
||||
class TestConsumeEntityState extends RenderCounter {
|
||||
@property({ attribute: false }) public entityId = "light.kitchen";
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
public stateObj?: HassEntity;
|
||||
|
||||
protected render() {
|
||||
return html`${this.stateObj?.state ?? "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-entity-states")
|
||||
class TestConsumeEntityStates extends RenderCounter {
|
||||
@property({ attribute: false }) public entityIds: string[] = [
|
||||
"light.kitchen",
|
||||
];
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["entityIds"] })
|
||||
public stateObjs?: Record<string, HassEntity>;
|
||||
|
||||
protected render() {
|
||||
return html`${Object.keys(this.stateObjs ?? {}).length}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-registry-entry")
|
||||
class TestConsumeRegistryEntry extends RenderCounter {
|
||||
@property({ attribute: false }) public entityId = "light.kitchen";
|
||||
|
||||
@state()
|
||||
@consumeEntityRegistryEntry({ entityIdPath: ["entityId"] })
|
||||
public entry?: EntityRegistryDisplayEntry;
|
||||
|
||||
protected render() {
|
||||
return html`${this.entry?.name ?? "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-localize")
|
||||
class TestConsumeLocalize extends RenderCounter {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
public localize?: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
return html`${this.localize ? "set" : "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the ~10 call sites that use `@consumeLocalize()` WITHOUT `@state()`.
|
||||
@customElement("test-consume-localize-no-state")
|
||||
class TestConsumeLocalizeNoState extends RenderCounter {
|
||||
@consumeLocalize()
|
||||
public localize?: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
return html`${this.localize ? "set" : "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers -----------------------------------------------------------------
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
const mount = async <T extends LitElement>(
|
||||
tag: string,
|
||||
context: any,
|
||||
initialValue: unknown
|
||||
): Promise<{ el: T; provider: ContextProvider<any> }> => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const provider = new ContextProvider(host, {
|
||||
context,
|
||||
initialValue,
|
||||
});
|
||||
const el = document.createElement(tag) as T;
|
||||
// The consumer must be a connected DOM descendant of the provider host.
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
return { el, provider };
|
||||
};
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
||||
describe("consumeEntityState", () => {
|
||||
it("renders on mount and reads the watched entity", async () => {
|
||||
const { el } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{
|
||||
"light.kitchen": makeEntity("light.kitchen", "on"),
|
||||
"light.bedroom": makeEntity("light.bedroom", "on"),
|
||||
}
|
||||
);
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does NOT re-render when an unrelated entity changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
// New states object, but the watched entity keeps the same reference.
|
||||
provider.setValue({
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeEntity("light.bedroom", "off"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when the watched entity changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("off");
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it("re-renders when the watched host property changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{
|
||||
"light.kitchen": makeEntity("light.kitchen", "on"),
|
||||
"light.bedroom": makeEntity("light.bedroom", "off"),
|
||||
}
|
||||
);
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
|
||||
el.entityId = "light.bedroom";
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("off");
|
||||
|
||||
// The provider is still wired, but churn that leaves the watched entity
|
||||
// untouched must not render.
|
||||
const renderCount = el.renderCount;
|
||||
provider.setValue({
|
||||
"light.kitchen": makeEntity("light.kitchen", "on"),
|
||||
"light.bedroom": el.stateObj!,
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(renderCount);
|
||||
});
|
||||
|
||||
it("clears the value and re-renders when the watched entity is removed", async () => {
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{ "light.kitchen": makeEntity("light.kitchen", "on") }
|
||||
);
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({});
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj).toBeUndefined();
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextSubscriptionController lifecycle", () => {
|
||||
it("mounts without a provider: value stays undefined and renders once", async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement(
|
||||
"test-consume-entity-state"
|
||||
) as TestConsumeEntityState;
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.stateObj).toBeUndefined();
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("unsubscribes on disconnect and re-subscribes on reconnect", async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const provider = new ContextProvider(host, {
|
||||
context: statesContext,
|
||||
initialValue: { "light.kitchen": makeEntity("light.kitchen", "on") },
|
||||
});
|
||||
const el = document.createElement(
|
||||
"test-consume-entity-state"
|
||||
) as TestConsumeEntityState;
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
|
||||
// Disconnect, then push an update — the disconnected element must not render.
|
||||
el.remove();
|
||||
const renderCount = el.renderCount;
|
||||
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(renderCount);
|
||||
|
||||
// Reconnect — it must re-subscribe and pick up the current value.
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeEntityStates", () => {
|
||||
it("does NOT re-render when an entity outside the watched set changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityStates>(
|
||||
"test-consume-entity-states",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeEntity("light.bedroom", "off"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when a watched entity changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityStates>(
|
||||
"test-consume-entity-states",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeEntityRegistryEntry", () => {
|
||||
it("does NOT re-render when an unrelated registry entry changes", async () => {
|
||||
const kitchen = makeRegistryEntry("light.kitchen", "Kitchen");
|
||||
const { el, provider } = await mount<TestConsumeRegistryEntry>(
|
||||
"test-consume-registry-entry",
|
||||
entitiesContext,
|
||||
{
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom"),
|
||||
}
|
||||
);
|
||||
expect(el.entry?.name).toBe("Kitchen");
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom 2"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when the watched registry entry changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeRegistryEntry>(
|
||||
"test-consume-registry-entry",
|
||||
entitiesContext,
|
||||
{
|
||||
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen"),
|
||||
}
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({
|
||||
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen renamed"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.entry?.name).toBe("Kitchen renamed");
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeLocalize", () => {
|
||||
const localizeA = (() => "A") as LocalizeFunc;
|
||||
const localizeB = (() => "B") as LocalizeFunc;
|
||||
|
||||
it("does NOT re-render when the i18n context changes but localize is identical", async () => {
|
||||
const { el, provider } = await mount<TestConsumeLocalize>(
|
||||
"test-consume-localize",
|
||||
internationalizationContext,
|
||||
makeI18n(localizeA)
|
||||
);
|
||||
expect(el.localize).toBe(localizeA);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
// New i18n object, same localize reference (e.g. only locale changed).
|
||||
provider.setValue(makeI18n(localizeA));
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when localize changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeLocalize>(
|
||||
"test-consume-localize",
|
||||
internationalizationContext,
|
||||
makeI18n(localizeA)
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue(makeI18n(localizeB));
|
||||
await el.updateComplete;
|
||||
expect(el.localize).toBe(localizeB);
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it("works without @state(): updates only when localize changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeLocalizeNoState>(
|
||||
"test-consume-localize-no-state",
|
||||
internationalizationContext,
|
||||
makeI18n(localizeA)
|
||||
);
|
||||
expect(el.localize).toBe(localizeA);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue(makeI18n(localizeA));
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue(makeI18n(localizeB));
|
||||
await el.updateComplete;
|
||||
expect(el.localize).toBe(localizeB);
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -947,7 +947,7 @@ exports[`generateStateHistoryChartLineData > matches snapshot for a climate enti
|
||||
"climate.thermostat",
|
||||
],
|
||||
"visualMap": undefined,
|
||||
"yAxisFractionDigits": 1,
|
||||
"yAxisFractionDigits": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ exports[`generateStatisticsChartData > appends current state for recent data 1`]
|
||||
"sensor.temp_indoor",
|
||||
],
|
||||
"unit": "°C",
|
||||
"yAxisFractionDigits": 1,
|
||||
"yAxisFractionDigits": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeYAxisFractionDigits } from "../../../src/components/chart/y-axis-fraction-digits";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
computeYAxisFractionDigits,
|
||||
createYAxisPrecisionBounds,
|
||||
} from "../../../src/components/chart/y-axis-fraction-digits";
|
||||
|
||||
describe("computeYAxisFractionDigits", () => {
|
||||
it("uses two decimals for a sub-unit range (e.g. gas prices around 1.85-2.00)", () => {
|
||||
@@ -22,8 +25,18 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
it("uses more decimals as the range shrinks", () => {
|
||||
expect(computeYAxisFractionDigits(0, 0.05)).toBe(3);
|
||||
expect(computeYAxisFractionDigits(0, 0.005)).toBe(4);
|
||||
// Values match the decimals ECharts actually renders for these ranges
|
||||
// (tick interval 0.01 -> 2 decimals, 0.001 -> 3 decimals).
|
||||
expect(computeYAxisFractionDigits(0, 0.05)).toBe(2);
|
||||
expect(computeYAxisFractionDigits(0, 0.005)).toBe(3);
|
||||
});
|
||||
|
||||
it("matches the tick interval without over-padding on a narrow range", () => {
|
||||
// A zoomed-in range that steps by 0.01 needs 2 decimals, not 3.
|
||||
expect(computeYAxisFractionDigits(21.02, 21.08)).toBe(2);
|
||||
// Ranges whose nice interval carries to a coarser power of ten stay tight.
|
||||
expect(computeYAxisFractionDigits(15, 15.004)).toBe(3);
|
||||
expect(computeYAxisFractionDigits(0, 0.04)).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to one decimal when min equals max", () => {
|
||||
@@ -36,7 +49,67 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
it("handles negative-to-positive ranges by the magnitude of the range", () => {
|
||||
expect(computeYAxisFractionDigits(-2, 2)).toBe(1);
|
||||
expect(computeYAxisFractionDigits(-2, 2)).toBe(0);
|
||||
expect(computeYAxisFractionDigits(-0.1, 0.1)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createYAxisPrecisionBounds", () => {
|
||||
it("computes digits from the visible extent when no bounds are set", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
|
||||
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
|
||||
expect(min({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
|
||||
min({ min: 21.02, max: 21.08 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("computes digits from numeric bounds and returns them unchanged", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
min: 1.85,
|
||||
max: 2,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
// Fixed bounds pin the range, so the visible extent is ignored
|
||||
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
|
||||
expect(max({ min: 1.9, max: 1.95 })).toBe(2);
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("resolves function bounds and passes their result through", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
min: ({ min: dataMin }) => dataMin - 1,
|
||||
max: ({ max: dataMax }) => dataMax + 1,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
expect(min({ min: 10, max: 11 })).toBe(9);
|
||||
expect(max({ min: 10, max: 11 })).toBe(12);
|
||||
// Range widened to 9..12 -> single decimal
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it("unions the extent with zero for anchored axes", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({
|
||||
includeZero: true,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
|
||||
min({ min: 20, max: 25 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// Small visible max close to zero -> more decimals
|
||||
min({ min: 0.02, max: 0.05 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +169,49 @@ describe("zwave_js-credentials", () => {
|
||||
);
|
||||
expect(result.user_id).toBe(1);
|
||||
});
|
||||
|
||||
it("creates a new user with a credential and returns the slot", async () => {
|
||||
// Omitting user_id makes set_user create a new user and write the
|
||||
// credential in one call, returning the allocated credential_slot.
|
||||
const hass = setUserResponse({ user_id: 1, credential_slot: 1 });
|
||||
|
||||
const result = await setZwaveUser(hass, ENTITY_ID, {
|
||||
user_name: "Alice",
|
||||
user_type: "general",
|
||||
active: true,
|
||||
credential_type: "pin_code",
|
||||
credential_data: "1234",
|
||||
});
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith(
|
||||
"zwave_js",
|
||||
"set_user",
|
||||
{
|
||||
user_name: "Alice",
|
||||
user_type: "general",
|
||||
active: true,
|
||||
credential_type: "pin_code",
|
||||
credential_data: "1234",
|
||||
},
|
||||
{ entity_id: ENTITY_ID },
|
||||
false,
|
||||
true
|
||||
);
|
||||
expect(result).toEqual({ user_id: 1, credential_slot: 1 });
|
||||
});
|
||||
|
||||
it("propagates errors from callService", async () => {
|
||||
const hass = {
|
||||
callService: vi.fn().mockRejectedValue(new Error("No slots")),
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
await expect(
|
||||
setZwaveUser(hass, ENTITY_ID, {
|
||||
credential_type: "pin_code",
|
||||
credential_data: "1234",
|
||||
})
|
||||
).rejects.toThrow("No slots");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteZwaveUser", () => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HaDialog } from "../../../src/components/ha-dialog";
|
||||
import type {
|
||||
DataEntryFlowStep,
|
||||
DataEntryFlowStepForm,
|
||||
DataEntryFlowStepMenu,
|
||||
} from "../../../src/data/data_entry_flow";
|
||||
import "../../../src/dialogs/config-flow/dialog-data-entry-flow";
|
||||
import type { FlowConfig } from "../../../src/dialogs/config-flow/show-dialog-data-entry-flow";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
vi.mock("../../../src/data/auth", () => ({
|
||||
autocompleteLoginFields: (schema: unknown) => schema,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/components/ha-dialog", () => {
|
||||
customElements.define("ha-dialog", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-button", () => {
|
||||
customElements.define("ha-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-dialog-footer", () => {
|
||||
customElements.define("ha-dialog-footer", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-icon-button", () => {
|
||||
customElements.define("ha-icon-button", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-form/ha-form", () => {
|
||||
customElements.define("ha-form", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/components/ha-list-item", () => {
|
||||
customElements.define("ha-list-item", class extends HTMLElement {});
|
||||
return {};
|
||||
});
|
||||
|
||||
const formStep: DataEntryFlowStepForm = {
|
||||
type: "form",
|
||||
flow_id: "test-flow",
|
||||
handler: "test",
|
||||
step_id: "user",
|
||||
data_schema: [
|
||||
{
|
||||
name: "name",
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
],
|
||||
errors: {},
|
||||
last_step: true,
|
||||
};
|
||||
|
||||
const menuStep: DataEntryFlowStepMenu = {
|
||||
type: "menu",
|
||||
flow_id: "test-flow",
|
||||
handler: "test",
|
||||
step_id: "user",
|
||||
menu_options: ["next"],
|
||||
};
|
||||
|
||||
const flowConfig: FlowConfig = {
|
||||
flowType: "config_flow",
|
||||
showDevices: false,
|
||||
createFlow: vi.fn().mockResolvedValue(formStep),
|
||||
fetchFlow: vi.fn().mockResolvedValue(formStep),
|
||||
handleFlowStep: vi.fn().mockResolvedValue(formStep),
|
||||
deleteFlow: vi.fn().mockResolvedValue(undefined),
|
||||
renderAbortDescription: () => "",
|
||||
renderShowFormStepHeader: () => "Test flow",
|
||||
renderShowFormStepDescription: () => "",
|
||||
renderShowFormStepFieldLabel: () => "Name",
|
||||
renderShowFormStepFieldHelper: () => "",
|
||||
renderShowFormStepFieldError: () => "",
|
||||
renderShowFormStepFieldLocalizeValue: (_, __, key) => key,
|
||||
renderShowFormStepSubmitButton: () => "Submit",
|
||||
renderExternalStepHeader: () => "",
|
||||
renderExternalStepDescription: () => "",
|
||||
renderCreateEntryDescription: () => "",
|
||||
renderShowFormProgressHeader: () => "",
|
||||
renderShowFormProgressDescription: () => "",
|
||||
renderMenuHeader: () => "",
|
||||
renderMenuDescription: () => "",
|
||||
renderMenuOption: (_, __, option) => option,
|
||||
renderMenuOptionDescription: () => "",
|
||||
renderLoadingDescription: () => "",
|
||||
};
|
||||
|
||||
const hass = {
|
||||
localize: (key: string) => key,
|
||||
devices: {},
|
||||
} as HomeAssistant;
|
||||
|
||||
const nextTask = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const openDialog = async (initialStep: DataEntryFlowStep = formStep) => {
|
||||
vi.mocked(flowConfig.createFlow).mockResolvedValueOnce(initialStep);
|
||||
const dialog = document.createElement("dialog-data-entry-flow");
|
||||
dialog.hass = hass;
|
||||
document.body.append(dialog);
|
||||
await dialog.showDialog({
|
||||
startFlowHandler: "test",
|
||||
flowConfig,
|
||||
});
|
||||
await nextTask();
|
||||
await dialog.updateComplete;
|
||||
return dialog;
|
||||
};
|
||||
|
||||
const innerDialog = (dialog: HTMLElementTagNameMap["dialog-data-entry-flow"]) =>
|
||||
dialog.shadowRoot!.querySelector("ha-dialog") as HaDialog;
|
||||
|
||||
describe("dialog-data-entry-flow dismissal", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("allows clean forms to be dismissed", async () => {
|
||||
const dialog = await openDialog();
|
||||
|
||||
expect(innerDialog(dialog).preventScrimClose).toBe(false);
|
||||
});
|
||||
|
||||
it("prevents dismissal after form values change", async () => {
|
||||
const dialog = await openDialog();
|
||||
const step = dialog.shadowRoot!.querySelector("step-flow-form")!;
|
||||
await step.updateComplete;
|
||||
const form = step.shadowRoot!.querySelector("ha-form")!;
|
||||
|
||||
form.dispatchEvent(
|
||||
new CustomEvent("value-changed", {
|
||||
detail: { value: { name: "New list" } },
|
||||
})
|
||||
);
|
||||
await nextTask();
|
||||
await dialog.updateComplete;
|
||||
|
||||
expect(innerDialog(dialog).preventScrimClose).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents dismissal after advancing to a clean form step", async () => {
|
||||
const dialog = await openDialog();
|
||||
|
||||
dialog.dispatchEvent(
|
||||
new CustomEvent("flow-update", {
|
||||
detail: {
|
||||
step: {
|
||||
...formStep,
|
||||
step_id: "second",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
await nextTask();
|
||||
await dialog.updateComplete;
|
||||
|
||||
const step = dialog.shadowRoot!.querySelector("step-flow-form")!;
|
||||
await step.updateComplete;
|
||||
|
||||
expect(innerDialog(dialog).preventScrimClose).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents dismissal while advancing from a menu step", async () => {
|
||||
vi.mocked(flowConfig.handleFlowStep).mockReturnValueOnce(
|
||||
Promise.race<DataEntryFlowStep>([])
|
||||
);
|
||||
const dialog = await openDialog(menuStep);
|
||||
const step = dialog.shadowRoot!.querySelector("step-flow-menu")!;
|
||||
await step.updateComplete;
|
||||
|
||||
step.shadowRoot!.querySelector<HTMLElement>("ha-list-item")!.click();
|
||||
await dialog.updateComplete;
|
||||
|
||||
expect(innerDialog(dialog).preventScrimClose).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents dismissal while a form is submitting", async () => {
|
||||
const dialog = await openDialog();
|
||||
const step = dialog.shadowRoot!.querySelector("step-flow-form")!;
|
||||
|
||||
step.dispatchEvent(
|
||||
new CustomEvent("flow-step-footer-state-changed", {
|
||||
detail: { loading: true },
|
||||
})
|
||||
);
|
||||
await dialog.updateComplete;
|
||||
|
||||
expect(innerDialog(dialog).preventScrimClose).toBe(true);
|
||||
});
|
||||
});
|
||||
+71
-272
@@ -4,81 +4,29 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:app
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import type { MoreInfoView } from "../../src/dialogs/more-info/const";
|
||||
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
||||
|
||||
/**
|
||||
* Each More info view renders one root element inside the dialog, plus one or
|
||||
* more characteristic descendants that prove the view actually populated rather
|
||||
* than rendering an empty shell. `text`, when set, asserts the element's text
|
||||
* instead of just its presence.
|
||||
*/
|
||||
const MORE_INFO_VIEW_ELEMENTS: {
|
||||
view: MoreInfoView;
|
||||
element: string;
|
||||
content: { selector: string; text?: string }[];
|
||||
}[] = [
|
||||
{
|
||||
view: "info",
|
||||
element: "ha-more-info-info",
|
||||
content: [
|
||||
{ selector: "more-info-light" },
|
||||
{ selector: "span.title", text: "Test Light" },
|
||||
],
|
||||
},
|
||||
{
|
||||
view: "history",
|
||||
element: "ha-more-info-history-and-logbook",
|
||||
// The demo loads the history component but not logbook.
|
||||
content: [{ selector: "ha-more-info-history" }],
|
||||
},
|
||||
{
|
||||
view: "settings",
|
||||
element: "ha-more-info-settings",
|
||||
// The scenario mocks config/entity_registry/get, so the real registry
|
||||
// panel renders instead of the "no unique ID" warning.
|
||||
content: [{ selector: "entity-registry-settings" }],
|
||||
},
|
||||
{
|
||||
view: "related",
|
||||
element: "ha-related-items",
|
||||
// search/related is mocked to return no relations, so the empty list
|
||||
// renders.
|
||||
content: [{ selector: "ha-related-items >> ha-list" }],
|
||||
},
|
||||
{
|
||||
view: "add_to",
|
||||
element: "ha-more-info-add-to",
|
||||
// Admin users get the default add-to action list.
|
||||
content: [{ selector: "ha-add-to-action-list" }],
|
||||
},
|
||||
{
|
||||
view: "details",
|
||||
element: "ha-more-info-details",
|
||||
// The details view renders the state and attributes cards.
|
||||
content: [{ selector: "ha-card" }],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The test app is built with __DEMO__=true which enables hash-based routing.
|
||||
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
|
||||
// Scenario selection uses query params: /?scenario=foo (always at root).
|
||||
|
||||
/** Navigate to a panel (hash routing) and wait for app to initialize. */
|
||||
async function goToPanel(page: Page, path: string) {
|
||||
// Paths starting with /? are root-level (scenario selection); panel paths
|
||||
// need to use hash routing (/#/panelname).
|
||||
const url = path.startsWith("/?") ? path : `/#${path}`;
|
||||
await page.goto(url);
|
||||
await page.waitForSelector("ha-test", { state: "attached" });
|
||||
// Wait for the app to finish initialising (hassConnected sets panels)
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
}
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
appSidebar,
|
||||
appSidebarConfig,
|
||||
appSidebarPanel,
|
||||
assertElementContent,
|
||||
defineLinkSmokeTests,
|
||||
defineRouteSmokeTests,
|
||||
ensureAppSidebarPanelVisible,
|
||||
goToPanel,
|
||||
} from "./app/src/helpers";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
appRouteSmokeGroups,
|
||||
configLinks,
|
||||
moreInfoViewElements,
|
||||
} from "./app/src/smoke";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell
|
||||
@@ -86,60 +34,40 @@ async function goToPanel(page: Page, path: string) {
|
||||
|
||||
test.describe("App shell", () => {
|
||||
test("page loads and ha-test element mounts", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const errors = trackPageErrors(page);
|
||||
|
||||
await goToPanel(page, "/");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
expect(errors).toHaveLength(0);
|
||||
await expect(page.locator("ha-test")).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
expectNoPageErrors(errors, undefined, []);
|
||||
});
|
||||
|
||||
test("sidebar renders with expected panels", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
|
||||
for (const urlPath of ["lovelace", "map", "energy", "history"]) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
|
||||
)
|
||||
).toBeAttached();
|
||||
}
|
||||
// Config has its own special element with id="sidebar-config"
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
)
|
||||
).toBeAttached();
|
||||
await Promise.all([
|
||||
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root.
|
||||
...["lovelace", "map", "energy", "history"].map((urlPath) =>
|
||||
expect(appSidebarPanel(page, urlPath)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
})
|
||||
),
|
||||
// Config has its own special element with id="sidebar-config".
|
||||
expect(appSidebarConfig(page)).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const historyLink = sidebar.locator("#sidebar-panel-history");
|
||||
if (!(await historyLink.isVisible().catch(() => false))) {
|
||||
await page.locator("ha-test >> home-assistant-main").evaluate((el) => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("hass-toggle-menu", {
|
||||
detail: { open: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await expect(historyLink).toBeVisible({ timeout: SHELL_TIMEOUT });
|
||||
const historyLink = await ensureAppSidebarPanelVisible(page, "history");
|
||||
await historyLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: SHELL_TIMEOUT });
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: QUICK_TIMEOUT });
|
||||
await expect(
|
||||
page.locator("ha-panel-history, history-panel").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
@@ -148,34 +76,29 @@ test.describe("App shell", () => {
|
||||
test("sidebar renders notification badge", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
const sidebar = appSidebar(page);
|
||||
await expect(sidebar).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
const notificationsLink = sidebar.locator("#sidebar-notifications");
|
||||
await expect(notificationsLink).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(notificationsLink).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(notificationsLink.locator(".badge").first()).toHaveText("1", {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("sidebar marks the active panel as selected", async ({ page }) => {
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
const lovelaceLink = sidebar.locator("#sidebar-panel-lovelace");
|
||||
const historyLink = sidebar.locator("#sidebar-panel-history");
|
||||
const lovelaceLink = appSidebarPanel(page, "lovelace");
|
||||
const historyLink = appSidebarPanel(page, "history");
|
||||
|
||||
await goToPanel(page, "/lovelace");
|
||||
await expect(lovelaceLink).toHaveClass(/selected/, {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
await expect(historyLink).not.toHaveClass(/selected/);
|
||||
|
||||
await goToPanel(page, "/history");
|
||||
await expect(historyLink).toHaveClass(/selected/, {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
await expect(lovelaceLink).not.toHaveClass(/selected/);
|
||||
});
|
||||
@@ -188,139 +111,16 @@ test.describe("App shell", () => {
|
||||
await goToPanel(page, "/?scenario=non-admin#/lovelace");
|
||||
|
||||
// Wait for the sidebar to mount before asserting on its contents.
|
||||
await expect(
|
||||
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
|
||||
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
// Config panel is adminOnly — should not appear for non-admin.
|
||||
const configLink = page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
);
|
||||
await expect(configLink).not.toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Panel navigation", () => {
|
||||
test("navigates to lovelace dashboard", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
await expect(
|
||||
page.locator("ha-panel-lovelace, hui-root").first()
|
||||
).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to energy panel", async ({ page }) => {
|
||||
await goToPanel(page, "/energy");
|
||||
await expect(
|
||||
page.locator("ha-panel-energy, energy-view").first()
|
||||
).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to map panel", async ({ page }) => {
|
||||
await goToPanel(page, "/map");
|
||||
await expect(
|
||||
page.locator("ha-panel-lovelace, hui-root").first()
|
||||
).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to history panel", async ({ page }) => {
|
||||
await goToPanel(page, "/history");
|
||||
await expect(
|
||||
page.locator("ha-panel-history, history-panel").first()
|
||||
).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to profile panel", async ({ page }) => {
|
||||
await goToPanel(page, "/profile");
|
||||
await expect(
|
||||
page.locator("ha-panel-profile, ha-config-user-profile").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools panel (formerly Developer tools)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every tool sub-page reachable under /config/tools, mapped to the custom
|
||||
* element tools-router mounts for it (see tools-router.ts). Asserting on the
|
||||
* specific element proves the route actually rendered its tool, not just the
|
||||
* shared ha-panel-tools shell.
|
||||
*/
|
||||
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
|
||||
{ route: "yaml", element: "tools-yaml-config" },
|
||||
{ route: "state", element: "tools-state" },
|
||||
{ route: "action", element: "tools-action" },
|
||||
{ route: "template", element: "tools-template" },
|
||||
{ route: "event", element: "tools-event" },
|
||||
{ route: "statistics", element: "tools-statistics" },
|
||||
{ route: "assist", element: "tools-assist" },
|
||||
{ route: "debug", element: "tools-debug" },
|
||||
];
|
||||
|
||||
test.describe("Tools panel", () => {
|
||||
test("base path renders the tools panel", async ({ page }) => {
|
||||
await goToPanel(page, "/config/tools");
|
||||
await expect(page.locator("ha-panel-tools")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
for (const { route, element } of TOOLS_SUBPAGES) {
|
||||
test(`renders the ${route} sub-page`, async ({ page }) => {
|
||||
await goToPanel(page, `/config/tools/${route}`);
|
||||
await expect(page.locator(element)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("service is an alias for the action tool", async ({ page }) => {
|
||||
await goToPanel(page, "/config/tools/service");
|
||||
await expect(page.locator("tools-action")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
await expect(appSidebarConfig(page)).not.toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools redirects (old developer-tools URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Tools redirects", () => {
|
||||
// The panel moved from top-level /developer-tools (pre-2026.2) to
|
||||
// /config/developer-tools (2026.2), then was renamed to /config/tools
|
||||
// (2026.8). Both old locations must redirect to the new one, and deep links
|
||||
// must keep their sub-page. See the updateRoute() redirect in
|
||||
// src/layouts/home-assistant.ts.
|
||||
for (const oldBase of ["/developer-tools", "/config/developer-tools"]) {
|
||||
test(`redirects ${oldBase} to the tools panel`, async ({ page }) => {
|
||||
await goToPanel(page, oldBase);
|
||||
await expect(page.locator("ha-panel-tools")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test(`redirects ${oldBase}/state to the state tool`, async ({ page }) => {
|
||||
await goToPanel(page, `${oldBase}/state`);
|
||||
await expect(page.locator("tools-state")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace
|
||||
@@ -349,7 +149,7 @@ test.describe("Lovelace dashboard", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Light more-info dialog", () => {
|
||||
for (const { view, element, content } of MORE_INFO_VIEW_ELEMENTS) {
|
||||
for (const { view, element, content } of moreInfoViewElements) {
|
||||
test(`opens more-info ${view} view for a light entity`, async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -387,16 +187,7 @@ test.describe("Light more-info dialog", () => {
|
||||
|
||||
// Each view should render its own characteristic content, not just an
|
||||
// empty shell.
|
||||
for (const { selector, text } of content) {
|
||||
const locator = dialog.locator(selector).first();
|
||||
if (text) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT });
|
||||
} else {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
}
|
||||
}
|
||||
await assertElementContent(dialog, content);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -452,18 +243,26 @@ test.describe("Theming", () => {
|
||||
|
||||
test.describe("Config panel", () => {
|
||||
test("config panel loads without JS errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const errors = trackPageErrors(page);
|
||||
|
||||
await goToPanel(page, "/config");
|
||||
await expect(
|
||||
page.locator("ha-panel-config, ha-config-dashboard").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
|
||||
|
||||
// Filter known pre-existing errors from vendor code
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
|
||||
const getDashboard = async (page) => {
|
||||
await goToPanel(page, "/config");
|
||||
const dashboard = page.locator("ha-config-dashboard");
|
||||
await expect(dashboard).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
return dashboard;
|
||||
};
|
||||
|
||||
defineLinkSmokeTests(
|
||||
"config links point to expected pages",
|
||||
configLinks,
|
||||
getDashboard
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import type { Panels } from "../../../../src/types";
|
||||
import type { PanelInfo } from "../../../../src/types";
|
||||
|
||||
export const e2eTestPanels: Panels = {
|
||||
interface E2ETestPanelInfo extends PanelInfo {
|
||||
testSelector?: string;
|
||||
}
|
||||
|
||||
export const e2eTestPanels: Record<string, E2ETestPanelInfo> = {
|
||||
home: {
|
||||
component_name: "home",
|
||||
icon: "mdi:home",
|
||||
title: "home",
|
||||
config: null,
|
||||
url_path: "home",
|
||||
testSelector: "ha-panel-home",
|
||||
},
|
||||
lovelace: {
|
||||
component_name: "lovelace",
|
||||
icon: "mdi:view-dashboard",
|
||||
title: "home",
|
||||
config: { mode: "storage" },
|
||||
url_path: "lovelace",
|
||||
testSelector: "ha-panel-lovelace, hui-root",
|
||||
},
|
||||
map: {
|
||||
component_name: "lovelace",
|
||||
@@ -14,6 +27,7 @@ export const e2eTestPanels: Panels = {
|
||||
title: "map",
|
||||
config: { mode: "storage" },
|
||||
url_path: "map",
|
||||
testSelector: "ha-panel-lovelace, hui-root",
|
||||
},
|
||||
energy: {
|
||||
component_name: "energy",
|
||||
@@ -21,6 +35,7 @@ export const e2eTestPanels: Panels = {
|
||||
title: "energy",
|
||||
config: null,
|
||||
url_path: "energy",
|
||||
testSelector: "ha-panel-energy, energy-view",
|
||||
},
|
||||
history: {
|
||||
component_name: "history",
|
||||
@@ -28,6 +43,71 @@ export const e2eTestPanels: Panels = {
|
||||
title: "history",
|
||||
config: null,
|
||||
url_path: "history",
|
||||
testSelector: "ha-panel-history, history-panel",
|
||||
},
|
||||
logbook: {
|
||||
component_name: "logbook",
|
||||
icon: "mdi:format-list-bulleted-type",
|
||||
title: "logbook",
|
||||
config: null,
|
||||
url_path: "logbook",
|
||||
testSelector: "ha-panel-logbook",
|
||||
},
|
||||
calendar: {
|
||||
component_name: "calendar",
|
||||
icon: "mdi:calendar",
|
||||
title: "calendar",
|
||||
config: null,
|
||||
url_path: "calendar",
|
||||
testSelector: "ha-panel-calendar",
|
||||
},
|
||||
todo: {
|
||||
component_name: "todo",
|
||||
icon: "mdi:clipboard-list",
|
||||
title: "todo",
|
||||
config: null,
|
||||
url_path: "todo",
|
||||
testSelector: "ha-panel-todo",
|
||||
},
|
||||
"media-browser": {
|
||||
component_name: "media-browser",
|
||||
icon: "mdi:play-box-multiple",
|
||||
title: "media_browser",
|
||||
config: null,
|
||||
url_path: "media-browser",
|
||||
testSelector: "ha-panel-media-browser",
|
||||
},
|
||||
light: {
|
||||
component_name: "light",
|
||||
icon: "mdi:lightbulb-group",
|
||||
title: "light",
|
||||
config: null,
|
||||
url_path: "light",
|
||||
testSelector: "ha-panel-light",
|
||||
},
|
||||
climate: {
|
||||
component_name: "climate",
|
||||
icon: "mdi:thermostat",
|
||||
title: "climate",
|
||||
config: null,
|
||||
url_path: "climate",
|
||||
testSelector: "ha-panel-climate",
|
||||
},
|
||||
maintenance: {
|
||||
component_name: "maintenance",
|
||||
icon: "mdi:wrench-clock",
|
||||
title: "maintenance",
|
||||
config: null,
|
||||
url_path: "maintenance",
|
||||
testSelector: "ha-panel-maintenance",
|
||||
},
|
||||
iframe: {
|
||||
component_name: "iframe",
|
||||
icon: "mdi:web",
|
||||
title: "iframe",
|
||||
config: { url: "/static/blank.html" },
|
||||
url_path: "iframe",
|
||||
testSelector: "ha-panel-iframe",
|
||||
},
|
||||
config: {
|
||||
component_name: "config",
|
||||
@@ -42,5 +122,20 @@ export const e2eTestPanels: Panels = {
|
||||
title: null,
|
||||
config: null,
|
||||
url_path: "profile",
|
||||
testSelector: "ha-panel-profile, ha-config-user-profile",
|
||||
},
|
||||
notfound: {
|
||||
component_name: "notfound",
|
||||
icon: null,
|
||||
title: null,
|
||||
config: null,
|
||||
url_path: "notfound",
|
||||
testSelector: "ha-panel-notfound",
|
||||
},
|
||||
};
|
||||
|
||||
export const e2ePanelRouteAssertions = new Map<string, string>(
|
||||
Object.values(e2eTestPanels).flatMap((panel): [string, string][] =>
|
||||
panel.testSelector ? [[`/${panel.url_path}`, panel.testSelector]] : []
|
||||
)
|
||||
);
|
||||
|
||||
+122
-2
@@ -2,6 +2,7 @@ import { customElement } from "lit/decorators";
|
||||
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
|
||||
import { navigate } from "../../../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import type { LogbookStreamMessage } from "../../../../src/data/logbook";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
@@ -10,7 +11,10 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockAssist } from "../../../../demo/src/stubs/assist";
|
||||
import { mockAuth } from "../../../../demo/src/stubs/auth";
|
||||
import { mockCloud } from "../../../../demo/src/stubs/cloud";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import {
|
||||
demoConfigEntries,
|
||||
mockConfigEntries,
|
||||
} from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEnergy } from "../../../../demo/src/stubs/energy";
|
||||
import { energyEntities } from "../../../../demo/src/stubs/entities";
|
||||
@@ -20,6 +24,8 @@ import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { mockIntegration } from "../../../../demo/src/stubs/integration";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
|
||||
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
|
||||
@@ -32,9 +38,56 @@ import { mockTemplate } from "../../../../demo/src/stubs/template";
|
||||
import { mockTodo } from "../../../../demo/src/stubs/todo";
|
||||
import { mockTranslations } from "../../../../demo/src/stubs/translations";
|
||||
import { mockUpdate } from "../../../../demo/src/stubs/update";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import { demoConfig } from "../../../../src/fake_data/demo_config";
|
||||
import { e2eTestPanels } from "./ha-test-panels";
|
||||
import { scenarios } from "./scenarios";
|
||||
|
||||
const E2E_CONFIG_COMPONENTS = [
|
||||
...demoConfig.components,
|
||||
"bluetooth",
|
||||
"dhcp",
|
||||
"hardware",
|
||||
"infrared",
|
||||
"insteon",
|
||||
"knx",
|
||||
"lovelace",
|
||||
"matter",
|
||||
"mqtt",
|
||||
"radio_frequency",
|
||||
"ssdp",
|
||||
"tag",
|
||||
"thread",
|
||||
"zeroconf",
|
||||
"zha",
|
||||
"zone",
|
||||
"zwave_js",
|
||||
];
|
||||
|
||||
const E2E_FILTER_ENTITIES: Record<string, EntityRegistryDisplayEntry> = {
|
||||
"infrared.remote": {
|
||||
entity_id: "infrared.remote",
|
||||
labels: [],
|
||||
platform: "demo",
|
||||
},
|
||||
"radio_frequency.remote": {
|
||||
entity_id: "radio_frequency.remote",
|
||||
labels: [],
|
||||
platform: "demo",
|
||||
},
|
||||
};
|
||||
|
||||
const MEDIA_BROWSER_ROOT = {
|
||||
title: "Media",
|
||||
media_content_id: "media-source://media_source",
|
||||
media_content_type: "app",
|
||||
media_class: "directory",
|
||||
can_play: false,
|
||||
can_expand: true,
|
||||
can_search: false,
|
||||
children: [],
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockHass: MockHomeAssistant;
|
||||
@@ -56,6 +109,13 @@ export class HaTest extends HomeAssistantAppEl {
|
||||
const initial: Partial<MockHomeAssistant> = {
|
||||
// Use the full panel map (history + config enabled)
|
||||
panels: e2eTestPanels,
|
||||
config: {
|
||||
...demoConfig,
|
||||
// Include common protocol and discovery integrations so Settings shows
|
||||
// the same high-level panels most real Home Assistant instances expose.
|
||||
components: E2E_CONFIG_COMPONENTS,
|
||||
},
|
||||
entities: E2E_FILTER_ENTITIES,
|
||||
panelUrl: (() => {
|
||||
const path = window.location.pathname;
|
||||
const dividerPos = path.indexOf("/", 1);
|
||||
@@ -100,11 +160,71 @@ export class HaTest extends HomeAssistantAppEl {
|
||||
mockEntityRegistry(hass, []);
|
||||
mockConfigEntries(hass);
|
||||
mockIcons(hass);
|
||||
mockIntegration(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
mockPersistentNotification(hass);
|
||||
mockSearch(hass);
|
||||
const { mockConfigPanel } =
|
||||
await import("../../../../demo/src/stubs/config-panel");
|
||||
mockConfigPanel(hass);
|
||||
|
||||
hass.mockWS("config_entries/get", (msg: { domain?: string }) => {
|
||||
const protocolEntries = demoConfigEntries
|
||||
.map(({ entry }) => entry)
|
||||
.concat(
|
||||
[
|
||||
{ entry_id: "mock-bluetooth", domain: "bluetooth" },
|
||||
{ entry_id: "mock-lovelace", domain: "lovelace" },
|
||||
].map((entry) => ({
|
||||
disabled_by: null,
|
||||
domain: entry.domain,
|
||||
entry_id: entry.entry_id,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
reason: null,
|
||||
source: "user" as const,
|
||||
state: "loaded" as const,
|
||||
supported_subentry_types: {},
|
||||
supports_options: false,
|
||||
supports_reconfigure: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
title: entry.domain,
|
||||
}))
|
||||
);
|
||||
return protocolEntries.filter(
|
||||
(entry) => !msg.domain || entry.domain === msg.domain
|
||||
);
|
||||
});
|
||||
hass.mockWS("radio_frequency/list", () => ({ transmitters: [] }));
|
||||
hass.mockWS("calendar/event/subscribe", (_msg, _currentHass, onChange) => {
|
||||
onChange?.({ events: [] });
|
||||
return () => undefined;
|
||||
});
|
||||
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
|
||||
const message: LogbookStreamMessage = { events: [] };
|
||||
onChange?.(message);
|
||||
return () => undefined;
|
||||
});
|
||||
hass.mockWS("config/auth/list", () => []);
|
||||
hass.mockWS("trace/contexts", () => ({}));
|
||||
hass.mockWS("media_source/browse_media", () => MEDIA_BROWSER_ROOT);
|
||||
|
||||
// Load default entities from the sections config
|
||||
hass.addEntities(energyEntities());
|
||||
hass.addEntities([
|
||||
...energyEntities(),
|
||||
{
|
||||
entity_id: "todo.shopping_list",
|
||||
state: "0",
|
||||
attributes: {
|
||||
friendly_name: "Shopping list",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
]);
|
||||
Promise.all([Promise.resolve(demoSections), localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
hass.addEntities(conf.entities(localize));
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import {
|
||||
defineParallelSmokeTests,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
} from "../../helpers";
|
||||
|
||||
const APP_MAIN_SELECTOR = "ha-test >> home-assistant-main";
|
||||
const APP_SIDEBAR_SELECTOR = `${APP_MAIN_SELECTOR} >> ha-sidebar`;
|
||||
|
||||
// The app e2e harness is built with __DEMO__=true, which enables hash routing.
|
||||
// Scenario selection uses query params at root: /?scenario=foo#/lovelace.
|
||||
export async function goToPanel(page: Page, path: string) {
|
||||
const url = path.startsWith("/?") ? path : `/#${path}`;
|
||||
await page.goto(url);
|
||||
await Promise.all([
|
||||
page.waitForSelector("ha-test", {
|
||||
state: "attached",
|
||||
timeout: SHELL_TIMEOUT,
|
||||
}),
|
||||
page.waitForFunction(
|
||||
() => "__mockHass" in window && Boolean(window.__mockHass),
|
||||
undefined,
|
||||
{ timeout: SHELL_TIMEOUT }
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
|
||||
|
||||
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
|
||||
|
||||
export const appSidebarPanel = (page: Page, panel: string) =>
|
||||
appSidebar(page).locator(`#sidebar-panel-${panel}`);
|
||||
|
||||
export const appSidebarConfig = (page: Page) =>
|
||||
appSidebar(page).locator("#sidebar-config");
|
||||
|
||||
export async function openAppSidebar(page: Page) {
|
||||
await appMain(page).evaluate((el) => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("hass-toggle-menu", {
|
||||
detail: { open: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureAppSidebarPanelVisible(page: Page, panel: string) {
|
||||
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
const link = appSidebarPanel(page, panel);
|
||||
if (!(await link.isVisible().catch(() => false))) {
|
||||
await openAppSidebar(page);
|
||||
}
|
||||
await expect(link).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
return link;
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export interface LinkSmokeCase {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export async function assertLink(
|
||||
root: Locator,
|
||||
{ href, label }: LinkSmokeCase
|
||||
) {
|
||||
const link = root.getByRole("link", {
|
||||
name: new RegExp(`^${escapeRegExp(label)}\\b`),
|
||||
});
|
||||
await expect(link).toHaveAttribute("href", href, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export function defineLinkSmokeTests(
|
||||
name: string,
|
||||
links: LinkSmokeCase[],
|
||||
getRoot: (page: Page) => Promise<Locator>
|
||||
) {
|
||||
test(name, async ({ page }) => {
|
||||
const root = await getRoot(page);
|
||||
|
||||
await Promise.all(
|
||||
links.map((link) =>
|
||||
test.step(`${link.label} links to ${link.href}`, async () => {
|
||||
await assertLink(root, link);
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ElementContentAssertion {
|
||||
selector: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ViewElementSmokeCase<TView extends string = string> {
|
||||
view: TView;
|
||||
element: string;
|
||||
content: ElementContentAssertion[];
|
||||
}
|
||||
|
||||
export async function assertElementContent(
|
||||
root: Locator,
|
||||
content: ElementContentAssertion[]
|
||||
) {
|
||||
await Promise.all(
|
||||
content.map(({ selector, text }) => {
|
||||
const locator = root.locator(selector).first();
|
||||
return text
|
||||
? expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT })
|
||||
: expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export interface RouteSmokeCase {
|
||||
name?: string;
|
||||
path: string;
|
||||
element: string;
|
||||
url?: RegExp;
|
||||
action?: (page: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RouteSmokeGroup {
|
||||
name: string;
|
||||
routes: RouteSmokeCase[];
|
||||
testName?: (route: RouteSmokeCase) => string;
|
||||
}
|
||||
|
||||
export const routeCase = (path: string, element: string): RouteSmokeCase => ({
|
||||
path,
|
||||
element,
|
||||
});
|
||||
|
||||
export const routeCases = (routes: [string, string][]): RouteSmokeCase[] =>
|
||||
routes.map(([path, element]) => routeCase(path, element));
|
||||
|
||||
export const rendersRoute = (route: RouteSmokeCase) => `renders ${route.path}`;
|
||||
|
||||
async function assertRouteSmoke(page: Page, route: RouteSmokeCase) {
|
||||
await goToPanel(page, route.path);
|
||||
await route.action?.(page);
|
||||
if (route.url) {
|
||||
await expect(page).toHaveURL(route.url, { timeout: QUICK_TIMEOUT });
|
||||
}
|
||||
await expect(page.locator(route.element).first()).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export function defineRouteSmokeTests(groups: RouteSmokeGroup[]) {
|
||||
defineParallelSmokeTests({
|
||||
groups,
|
||||
groupName: (group) => group.name,
|
||||
cases: (group) => group.routes,
|
||||
testName: (route, group) =>
|
||||
route.name ?? group.testName?.(route) ?? rendersRoute(route),
|
||||
run: async ({ page, smokeCase }) => {
|
||||
await assertRouteSmoke(page, smokeCase);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MoreInfoView } from "../../../../src/dialogs/more-info/const";
|
||||
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "../../helpers";
|
||||
import {
|
||||
rendersRoute,
|
||||
routeCase,
|
||||
routeCases,
|
||||
type LinkSmokeCase,
|
||||
type RouteSmokeCase,
|
||||
type RouteSmokeGroup,
|
||||
type ViewElementSmokeCase,
|
||||
} from "./helpers";
|
||||
import { e2ePanelRouteAssertions } from "./ha-test-panels";
|
||||
|
||||
// ── Config dashboard links ───────────────────────────────────────────────────
|
||||
|
||||
export const configLinks: LinkSmokeCase[] = [
|
||||
{ href: "/config/integrations", label: "Devices & services" },
|
||||
{ href: "/config/automation", label: "Automations & scenes" },
|
||||
{ href: "/config/areas", label: "Areas, labels & zones" },
|
||||
{ href: "/config/apps", label: "Apps" },
|
||||
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
|
||||
{ href: "/config/voice-assistants", label: "Voice assistants" },
|
||||
{ href: "/config/matter", label: "Matter" },
|
||||
{ href: "/config/zha", label: "Zigbee" },
|
||||
{ href: "/config/zwave_js", label: "Z-Wave" },
|
||||
{ href: "/knx", label: "KNX" },
|
||||
{ href: "/config/thread", label: "Thread" },
|
||||
{ href: "/config/bluetooth", label: "Bluetooth" },
|
||||
{ href: "/config/infrared", label: "Infrared" },
|
||||
{ href: "/config/radio-frequency", label: "Radio frequency" },
|
||||
{ href: "/insteon", label: "Insteon" },
|
||||
{ href: "/config/tags", label: "Tags" },
|
||||
{ href: "/config/person", label: "People" },
|
||||
{ href: "/config/system", label: "System" },
|
||||
{ href: "/config/tools", label: "Tools" },
|
||||
{ href: "/config/info", label: "About" },
|
||||
];
|
||||
|
||||
// ── More-info dialog views ───────────────────────────────────────────────────
|
||||
|
||||
export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
|
||||
{
|
||||
view: "info",
|
||||
element: "ha-more-info-info",
|
||||
content: [
|
||||
{ selector: "more-info-light" },
|
||||
{ selector: "span.title", text: "Test Light" },
|
||||
],
|
||||
},
|
||||
{
|
||||
view: "history",
|
||||
element: "ha-more-info-history-and-logbook",
|
||||
// The demo loads the history component but not logbook.
|
||||
content: [{ selector: "ha-more-info-history" }],
|
||||
},
|
||||
{
|
||||
view: "settings",
|
||||
element: "ha-more-info-settings",
|
||||
// The scenario mocks config/entity_registry/get, so the real registry
|
||||
// panel renders instead of the "no unique ID" warning.
|
||||
content: [{ selector: "entity-registry-settings" }],
|
||||
},
|
||||
{
|
||||
view: "related",
|
||||
element: "ha-related-items",
|
||||
// search/related is mocked to return no relations, so the empty list
|
||||
// renders.
|
||||
content: [{ selector: "ha-related-items >> ha-list" }],
|
||||
},
|
||||
{
|
||||
view: "add_to",
|
||||
element: "ha-more-info-add-to",
|
||||
// Admin users get the default add-to action list.
|
||||
content: [{ selector: "ha-add-to-action-list" }],
|
||||
},
|
||||
{
|
||||
view: "details",
|
||||
element: "ha-more-info-details",
|
||||
// The details view renders the state and attributes cards.
|
||||
content: [{ selector: "ha-card" }],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Route smoke tests ────────────────────────────────────────────────────────
|
||||
|
||||
interface E2ELovelaceRoot extends HTMLElement {
|
||||
lovelace?: {
|
||||
setEditMode: (editMode: boolean) => void;
|
||||
};
|
||||
}
|
||||
|
||||
async function setLovelaceEditMode(page: Page, editMode: boolean) {
|
||||
await page
|
||||
.locator("hui-root")
|
||||
.first()
|
||||
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
|
||||
await page
|
||||
.locator("hui-root")
|
||||
.first()
|
||||
.evaluate(async (el: Element, value) => {
|
||||
const root = el as E2ELovelaceRoot;
|
||||
const start = performance.now();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const check = () => {
|
||||
if (root.lovelace?.setEditMode) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (performance.now() - start > 2000) {
|
||||
reject(new Error("Lovelace edit mode action was not available"));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
root.lovelace!.setEditMode(value);
|
||||
}, editMode);
|
||||
}
|
||||
|
||||
const PANEL_ROUTE_ASSERTIONS = Array.from(
|
||||
e2ePanelRouteAssertions,
|
||||
([path, element]) => routeCase(path, element)
|
||||
);
|
||||
|
||||
const URL_NORMALIZATION_ASSERTIONS: RouteSmokeCase[] = [
|
||||
{
|
||||
name: "keeps the todo panel when adding the selected entity query",
|
||||
path: "/todo",
|
||||
element: "ha-panel-todo",
|
||||
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the history panel when removing the back query",
|
||||
path: "/?back=1#/history",
|
||||
element: "ha-panel-history, history-panel",
|
||||
url: /\/#\/history$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the logbook panel when removing the back query",
|
||||
path: "/?back=1#/logbook",
|
||||
element: "ha-panel-logbook",
|
||||
url: /\/#\/logbook$/,
|
||||
},
|
||||
{
|
||||
name: "keeps the lovelace panel when removing the edit query",
|
||||
path: "/lovelace",
|
||||
element: "ha-panel-lovelace, hui-root",
|
||||
url: /\/#\/lovelace\/home$/,
|
||||
action: async (page) => {
|
||||
await setLovelaceEditMode(page, true);
|
||||
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await setLovelaceEditMode(page, false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
|
||||
{ route: "yaml", element: "tools-yaml-config" },
|
||||
{ route: "state", element: "tools-state" },
|
||||
{ route: "action", element: "tools-action" },
|
||||
{ route: "template", element: "tools-template" },
|
||||
{ route: "event", element: "tools-event" },
|
||||
{ route: "statistics", element: "tools-statistics" },
|
||||
{ route: "assist", element: "tools-assist" },
|
||||
{ route: "debug", element: "tools-debug" },
|
||||
];
|
||||
|
||||
const TOOLS_ROUTE_ASSERTIONS = [
|
||||
routeCase("/config/tools", "ha-panel-tools"),
|
||||
...TOOLS_SUBPAGES.map(({ route, element }) =>
|
||||
routeCase(`/config/tools/${route}`, element)
|
||||
),
|
||||
routeCase("/config/tools/service", "tools-action"),
|
||||
];
|
||||
|
||||
const TOOLS_REDIRECT_ASSERTIONS = [
|
||||
...["/developer-tools", "/config/developer-tools"].flatMap((oldBase) => [
|
||||
routeCase(oldBase, "ha-panel-tools"),
|
||||
routeCase(`${oldBase}/state`, "tools-state"),
|
||||
]),
|
||||
];
|
||||
|
||||
const CONFIG_ROUTES = routeCases([
|
||||
["/config/integrations", "ha-config-integrations"],
|
||||
["/config/devices", "ha-config-devices"],
|
||||
["/config/entities", "ha-config-entities"],
|
||||
["/config/helpers", "ha-config-helpers"],
|
||||
["/config/areas", "ha-config-areas"],
|
||||
["/config/apps", "ha-config-apps"],
|
||||
["/config/app", "ha-config-app-dashboard"],
|
||||
["/config/automation", "ha-config-automation"],
|
||||
["/config/backup", "ha-config-backup"],
|
||||
["/config/scene", "ha-config-scene"],
|
||||
["/config/script", "ha-config-script"],
|
||||
["/config/blueprint", "ha-config-blueprint"],
|
||||
["/config/cloud", "ha-config-cloud"],
|
||||
["/config/energy", "ha-config-energy"],
|
||||
["/config/hardware", "ha-config-hardware"],
|
||||
["/config/labs", "ha-config-labs"],
|
||||
["/config/lovelace", "ha-config-lovelace"],
|
||||
["/config/person", "ha-config-person"],
|
||||
["/config/storage", "ha-config-section-storage"],
|
||||
["/config/tags", "ha-config-tags"],
|
||||
["/config/users", "ha-config-users"],
|
||||
["/config/voice-assistants", "ha-config-voice-assistants"],
|
||||
["/config/system", "ha-config-system-navigation"],
|
||||
["/config/info", "ha-config-info"],
|
||||
["/config/logs", "ha-config-logs"],
|
||||
["/config/general", "ha-config-section-general"],
|
||||
["/config/updates", "ha-config-section-updates"],
|
||||
["/config/repairs", "ha-config-repairs-dashboard"],
|
||||
["/config/analytics", "ha-config-section-analytics"],
|
||||
["/config/ai-tasks", "ha-config-section-ai-tasks"],
|
||||
["/config/labels", "ha-config-labels"],
|
||||
["/config/zone", "ha-config-zone"],
|
||||
["/config/network", "ha-config-section-network"],
|
||||
["/config/application_credentials", "ha-config-application-credentials"],
|
||||
["/config/bluetooth", "bluetooth-config-dashboard-router"],
|
||||
["/config/dhcp", "dhcp-config-panel"],
|
||||
["/config/infrared", "infrared-config-dashboard-router"],
|
||||
["/config/matter", "matter-config-panel"],
|
||||
["/config/mqtt", "mqtt-config-panel"],
|
||||
["/config/radio-frequency", "radio-frequency-config-dashboard-router"],
|
||||
["/config/ssdp", "ssdp-config-panel"],
|
||||
["/config/thread", "thread-config-panel"],
|
||||
["/config/zeroconf", "zeroconf-config-panel"],
|
||||
["/config/zha", "zha-config-dashboard-router"],
|
||||
["/config/zwave_js", "zwave_js-config-router"],
|
||||
]);
|
||||
|
||||
const NESTED_CONFIG_ROUTES = routeCases([
|
||||
["/config/integrations/dashboard", "ha-config-integrations-dashboard"],
|
||||
["/config/devices/dashboard", "ha-config-devices-dashboard"],
|
||||
["/config/areas/dashboard", "ha-config-areas-dashboard"],
|
||||
["/config/backup/settings", "ha-config-backup-settings"],
|
||||
]);
|
||||
|
||||
export const appRouteSmokeGroups: RouteSmokeGroup[] = [
|
||||
{
|
||||
name: "Panel navigation",
|
||||
routes: PANEL_ROUTE_ASSERTIONS,
|
||||
testName: (route) => `renders registered panel ${route.path}`,
|
||||
},
|
||||
{
|
||||
name: "Panel URL normalization",
|
||||
routes: URL_NORMALIZATION_ASSERTIONS,
|
||||
testName: (route) => route.name!,
|
||||
},
|
||||
{
|
||||
name: "Tools panel",
|
||||
routes: TOOLS_ROUTE_ASSERTIONS,
|
||||
testName: rendersRoute,
|
||||
},
|
||||
{
|
||||
name: "Tools redirects",
|
||||
routes: TOOLS_REDIRECT_ASSERTIONS,
|
||||
testName: (route) => `redirects ${route.path}`,
|
||||
},
|
||||
{
|
||||
name: "Config routes",
|
||||
routes: CONFIG_ROUTES,
|
||||
testName: rendersRoute,
|
||||
},
|
||||
{
|
||||
name: "Nested config routes",
|
||||
routes: NESTED_CONFIG_ROUTES,
|
||||
testName: rendersRoute,
|
||||
},
|
||||
];
|
||||
@@ -5,6 +5,29 @@
|
||||
// Usage: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { join, relative } from "path";
|
||||
|
||||
const findBlobReports = (dir) => {
|
||||
const files = [];
|
||||
const walk = (currentDir) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(entryPath);
|
||||
} else if (entry.name.endsWith(".zip")) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
walk(dir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const dest = "test/e2e/reports/blob";
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
@@ -12,10 +35,8 @@ mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const suite of ["demo", "app", "gallery"]) {
|
||||
const src = `test/e2e/reports/${suite}`;
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(src).filter((f) => f.endsWith(".zip"));
|
||||
} catch {
|
||||
const files = findBlobReports(src);
|
||||
if (!files?.length) {
|
||||
// Suite report directory doesn't exist (e.g. job was skipped or failed
|
||||
// before uploading). Skip gracefully.
|
||||
process.stderr.write(
|
||||
@@ -24,6 +45,7 @@ for (const suite of ["demo", "app", "gallery"]) {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
|
||||
const name = relative(src, file).replace(/[\\/]/g, "-");
|
||||
cpSync(file, join(dest, `${suite}-${name}`));
|
||||
}
|
||||
}
|
||||
|
||||
+29
-107
@@ -1,146 +1,68 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
NAVIGATION_TIMEOUT,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
appErrors as filterAppErrors,
|
||||
trackPageErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
activateDemoSidebarPanel,
|
||||
demoCardSelector,
|
||||
moreInfoCardSelector,
|
||||
openDemoSidebar,
|
||||
waitForDemoReady,
|
||||
} from "./demo/helpers";
|
||||
|
||||
test.describe("Home Assistant Demo", () => {
|
||||
// Collect JS errors during each test so we can assert no unexpected crashes.
|
||||
let pageErrors: Error[] = [];
|
||||
let pageErrors: ReturnType<typeof trackPageErrors>;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
pageErrors = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err));
|
||||
pageErrors = trackPageErrors(page);
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
function appErrors() {
|
||||
return filterAppErrors(pageErrors);
|
||||
}
|
||||
|
||||
// ── 1. Page loads ──────────────────────────────────────────────────────────
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
// The custom element is present in the document
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await waitForDemoReady(page);
|
||||
|
||||
// The launch screen should disappear once the app is ready
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
// No unhandled JS exceptions
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
// ── 2. Dashboard renders ───────────────────────────────────────────────────
|
||||
|
||||
test("dashboard renders Lovelace cards", async ({ page }) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await waitForDemoReady(page);
|
||||
|
||||
const cardSelector = [
|
||||
"hui-tile-card",
|
||||
"hui-entity-card",
|
||||
"hui-glance-card",
|
||||
"hui-button-card",
|
||||
"hui-markdown-card",
|
||||
].join(", ");
|
||||
|
||||
await expect(page.locator(cardSelector).first()).toBeVisible({
|
||||
await expect(page.locator(demoCardSelector).first()).toBeVisible({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await waitForDemoReady(page);
|
||||
await openDemoSidebar(page);
|
||||
await activateDemoSidebarPanel(page, "map");
|
||||
|
||||
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
|
||||
// inside a modal drawer that is closed by default. Open it first via
|
||||
// the ha-menu-button in the top app-bar.
|
||||
const menuButton = page.locator("ha-menu-button");
|
||||
if (await menuButton.isVisible()) {
|
||||
await menuButton.click();
|
||||
await expect(page.locator("ha-sidebar")).toBeVisible({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
} else {
|
||||
await expect(page.locator("ha-sidebar")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
const candidatePanels = ["map", "logbook", "history", "config"];
|
||||
|
||||
let clicked = false;
|
||||
for (const panel of candidatePanels) {
|
||||
const navItem = page.locator(`#sidebar-panel-${panel}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const visible = await navItem.isVisible().catch(() => false);
|
||||
if (visible) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await navItem.click();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(page).toHaveURL(new RegExp(`/${panel}`), {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(clicked, "No known sidebar panel was found to click").toBe(true);
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
// ── 4. More info dialog ───────────────────────────────────────────────────
|
||||
|
||||
test("clicking an entity card opens the more-info dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
await waitForDemoReady(page);
|
||||
|
||||
// Tile cards are the most common card type in the demo; fall back to other
|
||||
// clickable card types in case this platform renders a different layout.
|
||||
await expect(page.locator(moreInfoCardSelector).first()).toBeVisible({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await page.locator(moreInfoCardSelector).first().click();
|
||||
|
||||
// Tile cards are the most common card type in the demo; they open the
|
||||
// more-info dialog on click. Fall back to other clickable card types in
|
||||
// case the demo layout on this platform doesn't include tile cards.
|
||||
const cardSelector =
|
||||
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
|
||||
|
||||
await expect(page.locator(cardSelector).first()).toBeVisible({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await page.locator(cardSelector).first().click();
|
||||
|
||||
// The more-info dialog is a top-level custom element appended to the body.
|
||||
// We verify it is attached, then confirm it rendered by checking the title
|
||||
// span which is slotted into the light DOM and has real layout dimensions.
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(dialog.locator("span.title")).toBeVisible({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
const title = dialog.locator("span.title");
|
||||
await expect(title).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
|
||||
|
||||
export const demoCardSelector = [
|
||||
"hui-tile-card",
|
||||
"hui-entity-card",
|
||||
"hui-glance-card",
|
||||
"hui-button-card",
|
||||
"hui-markdown-card",
|
||||
].join(", ");
|
||||
|
||||
export const moreInfoCardSelector =
|
||||
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
|
||||
|
||||
export async function waitForDemoReady(page: Page) {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDemoSidebar(page: Page) {
|
||||
const menuButton = page.locator("ha-menu-button");
|
||||
if (await menuButton.isVisible()) {
|
||||
const modalDrawer = page.locator("ha-drawer").locator("wa-drawer");
|
||||
await Promise.all([
|
||||
modalDrawer.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
element.addEventListener("wa-after-show", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
),
|
||||
menuButton.click(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator("ha-sidebar")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateDemoSidebarPanel(page: Page, panel: string) {
|
||||
const navItem = page.locator(`#sidebar-panel-${panel}`);
|
||||
await expect(navItem).toBeVisible({ timeout: SHELL_TIMEOUT });
|
||||
await navItem.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${panel}(?:/|$)`), {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
+61
-306
@@ -7,362 +7,117 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:gallery
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Navigate to a gallery page via hash and wait for it to render. */
|
||||
async function goToGalleryPage(page: Page, hash: string) {
|
||||
// First visit to let ha-gallery boot up
|
||||
await page.goto(`/#${hash}`);
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// Wait for the demo element to appear in ha-gallery's shadow root.
|
||||
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
|
||||
// page-description is only rendered for pages that have a description field,
|
||||
// so we cannot use it as a universal readiness signal.
|
||||
const demoTag = `demo-${hash.replace("/", "-")}`;
|
||||
await page.waitForFunction((tag) => {
|
||||
const gallery = document.querySelector("ha-gallery") as any;
|
||||
return gallery?.shadowRoot?.querySelector(tag) != null;
|
||||
}, demoTag);
|
||||
}
|
||||
|
||||
/** Assert a gallery page loads without console errors.
|
||||
* Demo elements live inside ha-gallery's shadow root — use >> to pierce it.
|
||||
*/
|
||||
async function assertPageLoads(page: Page, hash: string, selector: string) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToGalleryPage(page, hash);
|
||||
|
||||
// Pierce ha-gallery's shadow root with >>
|
||||
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) => !IGNORED_ERRORS.some((re) => re.test(e))
|
||||
);
|
||||
expect(
|
||||
realErrors,
|
||||
`JS errors on ${hash}: ${realErrors.join("; ")}`
|
||||
).toHaveLength(0);
|
||||
}
|
||||
|
||||
// Errors that are gallery-harness artifacts rather than bugs in the component
|
||||
// under test. The Lit-context init-error family that used to live here is gone:
|
||||
// ha-gallery now provides fallback contexts for every demo (mirroring the real
|
||||
// app's root), so context-consuming components resolve `localize`, formatters,
|
||||
// config, etc. synchronously instead of throwing during init.
|
||||
const IGNORED_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
/Extension context/,
|
||||
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||
/^Object$/,
|
||||
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||
// from mock state data — pre-existing gallery data issue.
|
||||
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gallery shell
|
||||
// ---------------------------------------------------------------------------
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
defineGallerySmokeTests,
|
||||
expectGalleryDemoElement,
|
||||
galleryLocator,
|
||||
getGalleryDemo,
|
||||
goToGalleryHome,
|
||||
GALLERY_SHELL_IGNORED_PAGE_ERRORS,
|
||||
} from "./gallery/helpers";
|
||||
import { componentPages, lovelacePages, moreInfoPages } from "./gallery/pages";
|
||||
|
||||
test.describe("Gallery shell", () => {
|
||||
test("page loads and ha-gallery mounts", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const errors = trackPageErrors(page);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await goToGalleryHome(page);
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
expectNoPageErrors(errors, undefined, GALLERY_SHELL_IGNORED_PAGE_ERRORS);
|
||||
});
|
||||
|
||||
test("sidebar renders navigation links", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// The gallery drawer sidebar is inside ha-gallery's shadow root
|
||||
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
|
||||
await goToGalleryHome(page);
|
||||
await expect(galleryLocator(page, "ha-drawer")).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentPages: { name: string; selector: string }[] = [
|
||||
{ name: "ha-alert", selector: "demo-components-ha-alert" },
|
||||
{ name: "ha-badge", selector: "demo-components-ha-badge" },
|
||||
{ name: "ha-bar", selector: "demo-components-ha-bar" },
|
||||
{ name: "ha-button", selector: "demo-components-ha-button" },
|
||||
{ name: "ha-chips", selector: "demo-components-ha-chips" },
|
||||
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
|
||||
{
|
||||
name: "ha-control-circular-slider",
|
||||
selector: "demo-components-ha-control-circular-slider",
|
||||
},
|
||||
{
|
||||
name: "ha-control-number-buttons",
|
||||
selector: "demo-components-ha-control-number-buttons",
|
||||
},
|
||||
{
|
||||
name: "ha-control-select-menu",
|
||||
selector: "demo-components-ha-control-select-menu",
|
||||
},
|
||||
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
|
||||
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
|
||||
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
|
||||
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
|
||||
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
|
||||
{
|
||||
name: "ha-expansion-panel",
|
||||
selector: "demo-components-ha-expansion-panel",
|
||||
},
|
||||
{ name: "ha-faded", selector: "demo-components-ha-faded" },
|
||||
{ name: "ha-form", selector: "demo-components-ha-form" },
|
||||
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
|
||||
{
|
||||
name: "ha-hs-color-picker",
|
||||
selector: "demo-components-ha-hs-color-picker",
|
||||
},
|
||||
{ name: "ha-input", selector: "demo-components-ha-input" },
|
||||
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
|
||||
{ name: "ha-list", selector: "demo-components-ha-list" },
|
||||
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
|
||||
{
|
||||
name: "ha-progress-button",
|
||||
selector: "demo-components-ha-progress-button",
|
||||
},
|
||||
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
|
||||
{ name: "ha-selector", selector: "demo-components-ha-selector" },
|
||||
{ name: "ha-slider", selector: "demo-components-ha-slider" },
|
||||
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
|
||||
{ name: "ha-switch", selector: "demo-components-ha-switch" },
|
||||
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
|
||||
{ name: "ha-tip", selector: "demo-components-ha-tip" },
|
||||
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
|
||||
{
|
||||
name: "ha-adaptive-dialog",
|
||||
selector: "demo-components-ha-adaptive-dialog",
|
||||
},
|
||||
{
|
||||
name: "ha-adaptive-popover",
|
||||
selector: "demo-components-ha-adaptive-popover",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Components", () => {
|
||||
for (const { name, selector } of componentPages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `components/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const moreInfoPages: { name: string; selector: string }[] = [
|
||||
{ name: "light", selector: "demo-more-info-light" },
|
||||
{ name: "climate", selector: "demo-more-info-climate" },
|
||||
{ name: "cover", selector: "demo-more-info-cover" },
|
||||
{ name: "fan", selector: "demo-more-info-fan" },
|
||||
{ name: "humidifier", selector: "demo-more-info-humidifier" },
|
||||
{ name: "input-number", selector: "demo-more-info-input-number" },
|
||||
{ name: "input-text", selector: "demo-more-info-input-text" },
|
||||
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
|
||||
{ name: "lock", selector: "demo-more-info-lock" },
|
||||
{ name: "media-player", selector: "demo-more-info-media-player" },
|
||||
{ name: "number", selector: "demo-more-info-number" },
|
||||
{ name: "scene", selector: "demo-more-info-scene" },
|
||||
{ name: "timer", selector: "demo-more-info-timer" },
|
||||
{ name: "update", selector: "demo-more-info-update" },
|
||||
{ name: "vacuum", selector: "demo-more-info-vacuum" },
|
||||
{ name: "water-heater", selector: "demo-more-info-water-heater" },
|
||||
];
|
||||
|
||||
test.describe("More-info dialogs", () => {
|
||||
for (const { name, selector } of moreInfoPages) {
|
||||
test(`more-info ${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `more-info/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace card pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lovelacePages: { name: string; selector: string }[] = [
|
||||
{ name: "area-card", selector: "demo-lovelace-area-card" },
|
||||
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
|
||||
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
|
||||
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
|
||||
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
|
||||
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
|
||||
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
|
||||
{
|
||||
name: "grid-and-stack-card",
|
||||
selector: "demo-lovelace-grid-and-stack-card",
|
||||
},
|
||||
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
|
||||
{ name: "light-card", selector: "demo-lovelace-light-card" },
|
||||
{ name: "map-card", selector: "demo-lovelace-map-card" },
|
||||
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
|
||||
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
|
||||
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
|
||||
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
|
||||
{
|
||||
name: "picture-elements-card",
|
||||
selector: "demo-lovelace-picture-elements-card",
|
||||
},
|
||||
{
|
||||
name: "picture-entity-card",
|
||||
selector: "demo-lovelace-picture-entity-card",
|
||||
},
|
||||
{
|
||||
name: "picture-glance-card",
|
||||
selector: "demo-lovelace-picture-glance-card",
|
||||
},
|
||||
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
|
||||
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
|
||||
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
|
||||
];
|
||||
|
||||
test.describe("Lovelace cards", () => {
|
||||
for (const { name, selector } of lovelacePages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `lovelace/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specific interaction tests
|
||||
// ---------------------------------------------------------------------------
|
||||
defineGallerySmokeTests("Components", "components", componentPages);
|
||||
defineGallerySmokeTests("More-info dialogs", "more-info", moreInfoPages);
|
||||
defineGallerySmokeTests("Lovelace cards", "lovelace", lovelacePages);
|
||||
|
||||
test.describe("Component interactions", () => {
|
||||
test("ha-alert renders all four types", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-alert");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
const demo = await getGalleryDemo(page, "components/ha-alert");
|
||||
|
||||
// The demo uses property binding (.alertType) not attribute binding,
|
||||
// so we verify that multiple ha-alert elements are present.
|
||||
// The demo uses property binding (.alertType) not attribute binding, so we
|
||||
// verify that multiple ha-alert elements are present.
|
||||
const alerts = demo.locator("ha-alert");
|
||||
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
// There should be at least 4 alerts (one per type)
|
||||
await expect(alerts)
|
||||
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
|
||||
.catch(async () => {
|
||||
// If not exactly 4, just verify there are some (demo may include more)
|
||||
const count = await alerts.count();
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
await expect(alerts.nth(3)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
|
||||
test("ha-button renders primary action button", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-button");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-button");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
const demo = await getGalleryDemo(page, "components/ha-button");
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-button, mwc-button");
|
||||
});
|
||||
|
||||
test("ha-control-slider can be found in DOM", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-control-slider");
|
||||
const demo = page.locator(
|
||||
"ha-gallery >> demo-components-ha-control-slider"
|
||||
);
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
const demo = await getGalleryDemo(page, "components/ha-control-slider");
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-control-slider");
|
||||
});
|
||||
|
||||
test("ha-form renders schema-driven fields", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-form");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-form");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-form").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
const demo = await getGalleryDemo(page, "components/ha-form");
|
||||
|
||||
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-dialog");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expectGalleryDemoElement(demo, "ha-form");
|
||||
});
|
||||
|
||||
test("tile-card renders entity state", async ({ page }) => {
|
||||
await goToGalleryPage(page, "lovelace/tile-card");
|
||||
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
const demo = await getGalleryDemo(page, "lovelace/tile-card");
|
||||
|
||||
await expectGalleryDemoElement(demo, "hui-tile-card");
|
||||
});
|
||||
|
||||
test("more-info light renders controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/light");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-light");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
// Light more-info should contain a brightness or color-temp control
|
||||
await expect(
|
||||
demo
|
||||
.locator("ha-control-slider, ha-more-info-light, more-info-content")
|
||||
.first()
|
||||
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
});
|
||||
const demo = await getGalleryDemo(page, "more-info/light");
|
||||
|
||||
test("more-info cover renders position controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/cover");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-cover");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expectGalleryDemoElement(
|
||||
demo,
|
||||
"ha-control-slider, ha-more-info-light, more-info-content",
|
||||
SHELL_TIMEOUT
|
||||
);
|
||||
});
|
||||
|
||||
test("ha-gauge renders a gauge element", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-gauge");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area
|
||||
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
||||
await getGalleryDemo(page, "components/ha-gauge");
|
||||
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area.
|
||||
await expect(galleryLocator(page, "ha-gauge").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-switch toggles state on click", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-switch");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
const demo = await getGalleryDemo(page, "components/ha-switch");
|
||||
|
||||
// Find the first interactive (non-disabled) switch. Pull its checked state
|
||||
// from the property — ha-switch toggles via property, not the attribute.
|
||||
// from the property because ha-switch toggles via property, not attribute.
|
||||
const switchEl = demo.locator("ha-switch:not([disabled])").first();
|
||||
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
const before = await switchEl.evaluate((el: any) => el.checked === true);
|
||||
const before = await switchEl.evaluate(
|
||||
(el: HTMLElement & { checked?: boolean }) => el.checked === true
|
||||
);
|
||||
await switchEl.click();
|
||||
await expect
|
||||
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
})
|
||||
.poll(
|
||||
() =>
|
||||
switchEl.evaluate(
|
||||
(el: HTMLElement & { checked?: boolean }) => el.checked === true
|
||||
),
|
||||
{ timeout: QUICK_TIMEOUT }
|
||||
)
|
||||
.toBe(!before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import {
|
||||
defineParallelSmokeTests,
|
||||
expectNoPageErrors,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "../helpers";
|
||||
|
||||
export const GALLERY_SHELL_IGNORED_PAGE_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
];
|
||||
|
||||
export const GALLERY_IGNORED_PAGE_ERRORS: RegExp[] = [
|
||||
...GALLERY_SHELL_IGNORED_PAGE_ERRORS,
|
||||
/Extension context/,
|
||||
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||
/^Object$/,
|
||||
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||
// from mock state data - pre-existing gallery data issue.
|
||||
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||
];
|
||||
|
||||
export interface GalleryPageSmokeCase {
|
||||
name: string;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export const galleryLocator = (page: Page, selector: string) =>
|
||||
page.locator(`ha-gallery >> ${selector}`);
|
||||
|
||||
const galleryDemoTag = (hash: string) => `demo-${hash.replace(/\//g, "-")}`;
|
||||
|
||||
async function waitForGalleryReady(page: Page) {
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function goToGalleryHome(page: Page) {
|
||||
await page.goto("/");
|
||||
await waitForGalleryReady(page);
|
||||
}
|
||||
|
||||
export async function goToGalleryPage(page: Page, hash: string) {
|
||||
await page.goto(`/#${hash}`);
|
||||
}
|
||||
|
||||
async function expectGalleryPageSelector(page: Page, selector: string) {
|
||||
const locator = galleryLocator(page, selector).first();
|
||||
await expect(locator).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
return locator;
|
||||
}
|
||||
|
||||
export async function getGalleryDemo(page: Page, hash: string) {
|
||||
await goToGalleryPage(page, hash);
|
||||
return expectGalleryPageSelector(page, galleryDemoTag(hash));
|
||||
}
|
||||
|
||||
export async function assertGalleryPageLoads(
|
||||
page: Page,
|
||||
hash: string,
|
||||
selector: string
|
||||
) {
|
||||
const errors = trackPageErrors(page);
|
||||
await goToGalleryPage(page, hash);
|
||||
await expectGalleryPageSelector(page, selector);
|
||||
expectNoPageErrors(errors, hash, GALLERY_IGNORED_PAGE_ERRORS);
|
||||
}
|
||||
|
||||
export function defineGallerySmokeTests(
|
||||
groupName: string,
|
||||
routePrefix: string,
|
||||
pages: GalleryPageSmokeCase[]
|
||||
) {
|
||||
defineParallelSmokeTests({
|
||||
groups: [{ name: groupName, routePrefix, pages }],
|
||||
groupName: (group) => group.name,
|
||||
cases: (group) => group.pages,
|
||||
testName: (smokeCase) => `${smokeCase.name} renders without errors`,
|
||||
run: async ({ page, group, smokeCase }) => {
|
||||
await assertGalleryPageLoads(
|
||||
page,
|
||||
`${group.routePrefix}/${smokeCase.name}`,
|
||||
smokeCase.selector
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectGalleryDemoElement(
|
||||
demo: Locator,
|
||||
selector: string,
|
||||
timeout = QUICK_TIMEOUT
|
||||
) {
|
||||
await expect(demo.locator(selector).first()).toBeAttached({ timeout });
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { GalleryPageSmokeCase } from "./helpers";
|
||||
|
||||
export const componentPages: GalleryPageSmokeCase[] = [
|
||||
{ name: "ha-alert", selector: "demo-components-ha-alert" },
|
||||
{ name: "ha-badge", selector: "demo-components-ha-badge" },
|
||||
{ name: "ha-bar", selector: "demo-components-ha-bar" },
|
||||
{ name: "ha-button", selector: "demo-components-ha-button" },
|
||||
{ name: "ha-chips", selector: "demo-components-ha-chips" },
|
||||
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
|
||||
{
|
||||
name: "ha-control-circular-slider",
|
||||
selector: "demo-components-ha-control-circular-slider",
|
||||
},
|
||||
{
|
||||
name: "ha-control-number-buttons",
|
||||
selector: "demo-components-ha-control-number-buttons",
|
||||
},
|
||||
{
|
||||
name: "ha-control-select-menu",
|
||||
selector: "demo-components-ha-control-select-menu",
|
||||
},
|
||||
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
|
||||
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
|
||||
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
|
||||
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
|
||||
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
|
||||
{
|
||||
name: "ha-expansion-panel",
|
||||
selector: "demo-components-ha-expansion-panel",
|
||||
},
|
||||
{ name: "ha-faded", selector: "demo-components-ha-faded" },
|
||||
{ name: "ha-form", selector: "demo-components-ha-form" },
|
||||
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
|
||||
{
|
||||
name: "ha-hs-color-picker",
|
||||
selector: "demo-components-ha-hs-color-picker",
|
||||
},
|
||||
{ name: "ha-input", selector: "demo-components-ha-input" },
|
||||
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
|
||||
{ name: "ha-list", selector: "demo-components-ha-list" },
|
||||
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
|
||||
{
|
||||
name: "ha-progress-button",
|
||||
selector: "demo-components-ha-progress-button",
|
||||
},
|
||||
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
|
||||
{ name: "ha-selector", selector: "demo-components-ha-selector" },
|
||||
{ name: "ha-slider", selector: "demo-components-ha-slider" },
|
||||
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
|
||||
{ name: "ha-switch", selector: "demo-components-ha-switch" },
|
||||
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
|
||||
{ name: "ha-tip", selector: "demo-components-ha-tip" },
|
||||
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
|
||||
{
|
||||
name: "ha-adaptive-dialog",
|
||||
selector: "demo-components-ha-adaptive-dialog",
|
||||
},
|
||||
{
|
||||
name: "ha-adaptive-popover",
|
||||
selector: "demo-components-ha-adaptive-popover",
|
||||
},
|
||||
];
|
||||
|
||||
export const moreInfoPages: GalleryPageSmokeCase[] = [
|
||||
{ name: "light", selector: "demo-more-info-light" },
|
||||
{ name: "climate", selector: "demo-more-info-climate" },
|
||||
{ name: "cover", selector: "demo-more-info-cover" },
|
||||
{ name: "fan", selector: "demo-more-info-fan" },
|
||||
{ name: "humidifier", selector: "demo-more-info-humidifier" },
|
||||
{ name: "input-number", selector: "demo-more-info-input-number" },
|
||||
{ name: "input-text", selector: "demo-more-info-input-text" },
|
||||
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
|
||||
{ name: "lock", selector: "demo-more-info-lock" },
|
||||
{ name: "media-player", selector: "demo-more-info-media-player" },
|
||||
{ name: "number", selector: "demo-more-info-number" },
|
||||
{ name: "scene", selector: "demo-more-info-scene" },
|
||||
{ name: "timer", selector: "demo-more-info-timer" },
|
||||
{ name: "update", selector: "demo-more-info-update" },
|
||||
{ name: "vacuum", selector: "demo-more-info-vacuum" },
|
||||
{ name: "water-heater", selector: "demo-more-info-water-heater" },
|
||||
];
|
||||
|
||||
export const lovelacePages: GalleryPageSmokeCase[] = [
|
||||
{ name: "area-card", selector: "demo-lovelace-area-card" },
|
||||
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
|
||||
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
|
||||
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
|
||||
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
|
||||
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
|
||||
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
|
||||
{
|
||||
name: "grid-and-stack-card",
|
||||
selector: "demo-lovelace-grid-and-stack-card",
|
||||
},
|
||||
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
|
||||
{ name: "light-card", selector: "demo-lovelace-light-card" },
|
||||
{ name: "map-card", selector: "demo-lovelace-map-card" },
|
||||
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
|
||||
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
|
||||
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
|
||||
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
|
||||
{
|
||||
name: "picture-elements-card",
|
||||
selector: "demo-lovelace-picture-elements-card",
|
||||
},
|
||||
{
|
||||
name: "picture-entity-card",
|
||||
selector: "demo-lovelace-picture-entity-card",
|
||||
},
|
||||
{
|
||||
name: "picture-glance-card",
|
||||
selector: "demo-lovelace-picture-glance-card",
|
||||
},
|
||||
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
|
||||
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
|
||||
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
|
||||
];
|
||||
+64
-17
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Shared helpers and constants for Playwright e2e suites.
|
||||
*/
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
// ── Timeouts ────────────────────────────────────────────────────────────────
|
||||
// Centralised so tweaks don't require search-and-replace across spec files.
|
||||
@@ -17,21 +18,67 @@ export const NAVIGATION_TIMEOUT = 30_000;
|
||||
|
||||
// ── Error filtering ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter out errors known to be unrelated to the app under test:
|
||||
* - ResizeObserver loop notifications (browser quirk, harmless)
|
||||
* - Non-Error rejections (mock data throws plain objects)
|
||||
* - Browser extension noise
|
||||
*/
|
||||
export function appErrors(errors: { message: string }[] | string[]) {
|
||||
const messages =
|
||||
typeof errors[0] === "string"
|
||||
? (errors as string[])
|
||||
: (errors as { message: string }[]).map((e) => e.message);
|
||||
return messages.filter(
|
||||
(msg) =>
|
||||
!msg.includes("ResizeObserver") &&
|
||||
!msg.includes("Non-Error") &&
|
||||
!msg.includes("Extension context")
|
||||
);
|
||||
type PageError = { message: string } | string;
|
||||
|
||||
export const IGNORED_PAGE_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
/Extension context/,
|
||||
];
|
||||
|
||||
export function trackPageErrors(page: Page) {
|
||||
const errors: PageError[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error));
|
||||
return errors;
|
||||
}
|
||||
|
||||
function pageErrors(errors: PageError[], ignoredErrors = IGNORED_PAGE_ERRORS) {
|
||||
return errors
|
||||
.map((error) => (typeof error === "string" ? error : error.message))
|
||||
.filter((message) =>
|
||||
ignoredErrors.every((pattern) => !pattern.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
export function expectNoPageErrors(
|
||||
errors: PageError[],
|
||||
context?: string,
|
||||
ignoredErrors = IGNORED_PAGE_ERRORS
|
||||
) {
|
||||
const realErrors = pageErrors(errors, ignoredErrors);
|
||||
const details = realErrors.length ? `: ${realErrors.join("; ")}` : "";
|
||||
expect(
|
||||
realErrors,
|
||||
context ? `JS errors on ${context}${details}` : `JS errors${details}`
|
||||
).toHaveLength(0);
|
||||
}
|
||||
|
||||
export interface DefineParallelSmokeTestsOptions<TGroup, TCase> {
|
||||
groups: readonly TGroup[];
|
||||
groupName: (group: TGroup) => string;
|
||||
cases: (group: TGroup) => readonly TCase[];
|
||||
testName: (smokeCase: TCase, group: TGroup) => string;
|
||||
run: (context: {
|
||||
page: Page;
|
||||
group: TGroup;
|
||||
smokeCase: TCase;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function defineParallelSmokeTests<TGroup, TCase>({
|
||||
groups,
|
||||
groupName,
|
||||
cases,
|
||||
testName,
|
||||
run,
|
||||
}: DefineParallelSmokeTestsOptions<TGroup, TCase>) {
|
||||
for (const group of groups) {
|
||||
test.describe(groupName(group), () => {
|
||||
for (const smokeCase of cases(group)) {
|
||||
test(testName(smokeCase, group), async ({ page }) => {
|
||||
await run({ page, group, smokeCase });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const DEFAULT_LOCAL_WORKERS = "60%";
|
||||
const VALID_WORKERS = /^[1-9]\d*%?$/;
|
||||
|
||||
export const getE2EWorkers = (): number | string => {
|
||||
if (process.env.CI) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const workers = process.env.E2E_WORKERS;
|
||||
if (!workers) {
|
||||
return DEFAULT_LOCAL_WORKERS;
|
||||
}
|
||||
|
||||
if (!VALID_WORKERS.test(workers)) {
|
||||
throw new Error(
|
||||
`E2E_WORKERS must be a positive integer or percentage, received "${workers}".`
|
||||
);
|
||||
}
|
||||
|
||||
return workers.endsWith("%") ? workers : Number(workers);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { getE2EWorkers } from "./playwright-workers";
|
||||
|
||||
const APP_PORT = 8095;
|
||||
const APP_BASE_URL = `http://localhost:${APP_PORT}`;
|
||||
@@ -11,8 +12,10 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
fullyParallel: true,
|
||||
workers: getE2EWorkers(),
|
||||
|
||||
outputDir: "test-results",
|
||||
outputDir: "test-results/app",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
|
||||
|
||||
use: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { getE2EWorkers } from "./playwright-workers";
|
||||
|
||||
// Port 8090 matches the `develop_demo` dev server (rspack-dev-server-demo).
|
||||
// This means running `demo/script/develop_demo` and then `yarn test:e2e:local`
|
||||
@@ -16,8 +17,10 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
fullyParallel: true,
|
||||
workers: getE2EWorkers(),
|
||||
|
||||
outputDir: "test-results",
|
||||
outputDir: "test-results/demo",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
|
||||
|
||||
use: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { getE2EWorkers } from "./playwright-workers";
|
||||
|
||||
const GALLERY_PORT = 8100;
|
||||
const GALLERY_BASE_URL = `http://localhost:${GALLERY_PORT}`;
|
||||
@@ -11,8 +12,10 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
fullyParallel: true,
|
||||
workers: getE2EWorkers(),
|
||||
|
||||
outputDir: "test-results",
|
||||
outputDir: "test-results/gallery",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
|
||||
|
||||
use: {
|
||||
|
||||
@@ -27,19 +27,24 @@ const collectFailures = (report) => {
|
||||
const here = suite.title ? [...titlePath, suite.title] : titlePath;
|
||||
for (const spec of suite.specs ?? []) {
|
||||
if (spec.ok) continue;
|
||||
const errors = [];
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const result of test.results ?? []) {
|
||||
const attempts = [];
|
||||
for (const [index, result] of (test.results ?? []).entries()) {
|
||||
const errors = [];
|
||||
for (const err of result.errors ?? []) {
|
||||
if (err.message) errors.push(stripAnsi(err.message));
|
||||
}
|
||||
if (errors.length) attempts.push({ index: index + 1, errors });
|
||||
}
|
||||
if (!attempts.length) continue;
|
||||
|
||||
const title = [...here, spec.title].join(" › ");
|
||||
failures.push({
|
||||
title: test.projectName ? `\`${test.projectName}\` ${title}` : title,
|
||||
location: `${spec.file ?? suite.file ?? ""}:${spec.line ?? ""}`,
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
failures.push({
|
||||
title: [...here, spec.title].join(" › "),
|
||||
location: `${spec.file ?? suite.file ?? ""}:${spec.line ?? ""}`,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
for (const child of suite.suites ?? []) walk(child, here);
|
||||
};
|
||||
@@ -50,7 +55,12 @@ const collectFailures = (report) => {
|
||||
|
||||
const formatFailure = (failure) => {
|
||||
const output =
|
||||
failure.errors.join("\n\n").trim() || "(no error output captured)";
|
||||
failure.attempts
|
||||
.map(({ index, errors }) =>
|
||||
[`Attempt ${index}`, "", errors.join("\n\n")].join("\n")
|
||||
)
|
||||
.join("\n\n")
|
||||
.trim() || "(no error output captured)";
|
||||
return [
|
||||
`<details><summary>❌ ${failure.title} <code>${failure.location}</code></summary>`,
|
||||
"",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user