mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-16 00:57:34 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be27615851 | |||
| 48f3d45a2e | |||
| f2a049b7c6 | |||
| 1e691f0b64 | |||
| 23d7f4772a | |||
| bd23592e77 | |||
| 0fe80ef5f9 | |||
| 4327871c30 | |||
| 88381e0b11 | |||
| f44590a2aa | |||
| 1c03daac41 | |||
| a2216c6bf5 | |||
| 4a755e00ac | |||
| 36a9da37f8 | |||
| cee5361525 | |||
| 5f46594ee2 | |||
| 58bcf1e865 | |||
| 196d71c4f7 | |||
| 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,70 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend validation workflow. Use when 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.
|
||||
|
||||
## 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]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally 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 | Server | Test command |
|
||||
| ------- | ------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. 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
|
||||
@@ -19,6 +19,9 @@ outputs:
|
||||
netlify_url:
|
||||
description: The deployed URL
|
||||
value: ${{ steps.deploy.outputs.netlify_url }}
|
||||
unique_deploy_url:
|
||||
description: The unique hash-based deployment URL for this specific build
|
||||
value: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -32,9 +35,18 @@ runs:
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
# Execute the deployment
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Collect the urls from the deployment output
|
||||
NETLIFY_URL=$(jq -r '.url // .deploy_url' deploy_output.json)
|
||||
SITE_NAME=$(jq -r '.site_name' deploy_output.json)
|
||||
DEPLOY_ID=$(jq -r '.deploy_id' deploy_output.json)
|
||||
UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app"
|
||||
|
||||
echo "netlify_url=$NETLIFY_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "unique_deploy_url=$UNIQUE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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.
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import type { DemoConfig } from "./types";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
if (typeof theme === "function") {
|
||||
hass.mockTheme(theme());
|
||||
return;
|
||||
}
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./sections").then((mod) => mod.demoSections),
|
||||
@@ -31,5 +40,5 @@ export const setDemoConfig = async (
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
hass.mockTheme(config.theme());
|
||||
applyDemoTheme(hass, config.theme);
|
||||
};
|
||||
|
||||
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
theme: () => ({}),
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
@@ -12,5 +15,5 @@ export interface DemoConfig {
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
theme: () => Record<string, string> | null;
|
||||
theme: DemoTheme;
|
||||
}
|
||||
|
||||
+2
-4
@@ -5,7 +5,7 @@ import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
@@ -173,9 +173,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
hass.addEntities(conf.entities(localize));
|
||||
if (conf.theme) {
|
||||
hass.mockTheme(conf.theme());
|
||||
}
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
|
||||
let sidebarChangeCallback;
|
||||
let sidebarChangeCallback: ((data: { value: unknown }) => void) | undefined;
|
||||
let themeChangeCallback: ((data: { value: ThemeSettings }) => void) | undefined;
|
||||
|
||||
const THEME_STORAGE_KEY = "demo_theme";
|
||||
const DEFAULT_THEME: ThemeSettings = { theme: "default", dark: false };
|
||||
|
||||
export const getDemoTheme = (
|
||||
fallback: ThemeSettings = DEFAULT_THEME
|
||||
): ThemeSettings => {
|
||||
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (!storedTheme) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(storedTheme) as ThemeSettings;
|
||||
} catch {
|
||||
localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
|
||||
hass.mockWS("frontend/get_user_data", ({ key }) => ({
|
||||
value: key === "theme" ? getDemoTheme() : null,
|
||||
}));
|
||||
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
|
||||
if (key === "theme") {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value));
|
||||
themeChangeCallback?.({ value });
|
||||
localStorage.removeItem("selectedTheme");
|
||||
}
|
||||
if (key === "sidebar") {
|
||||
sidebarChangeCallback?.({
|
||||
value: {
|
||||
@@ -14,10 +41,18 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
|
||||
hass.mockWS("frontend/subscribe_user_data", (msg, currentHass, onChange) => {
|
||||
if (msg.key === "sidebar") {
|
||||
sidebarChangeCallback = onChange;
|
||||
}
|
||||
if (msg.key === "theme") {
|
||||
themeChangeCallback = onChange;
|
||||
onChange?.({
|
||||
value: getDemoTheme(currentHass.selectedTheme ?? undefined),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
}
|
||||
onChange?.({ value: null });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
|
||||
+28
-27
@@ -12,7 +12,7 @@
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "tsc",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
|
||||
@@ -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-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.10",
|
||||
"@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-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -83,8 +83,8 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.1",
|
||||
"@tsparticles/preset-links": "4.3.1",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -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.11",
|
||||
"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",
|
||||
@@ -145,14 +145,14 @@
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.63.0",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@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,12 +168,13 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.6.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -202,17 +203,17 @@
|
||||
"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",
|
||||
"tar": "7.5.19",
|
||||
"tar": "7.5.20",
|
||||
"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 +229,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 |
@@ -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}`
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
formatClockCardDate,
|
||||
} from "../panels/lovelace/cards/clock/clock-date-format";
|
||||
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-sortable";
|
||||
|
||||
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
type ClockDateSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
];
|
||||
|
||||
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "",
|
||||
};
|
||||
|
||||
const getClockDatePartSection = (
|
||||
part: ClockCardDatePart
|
||||
): ClockDatePartSection => {
|
||||
if (part.startsWith("weekday-")) {
|
||||
return "weekday";
|
||||
}
|
||||
|
||||
if (part.startsWith("day-")) {
|
||||
return "day";
|
||||
}
|
||||
|
||||
if (part.startsWith("month-")) {
|
||||
return "month";
|
||||
}
|
||||
|
||||
if (part.startsWith("year-")) {
|
||||
return "year";
|
||||
}
|
||||
|
||||
return "separator";
|
||||
};
|
||||
|
||||
interface ClockDatePartSectionData {
|
||||
id: ClockDatePartSection;
|
||||
title: string;
|
||||
items: PickerComboBoxItem[];
|
||||
}
|
||||
|
||||
interface ClockDatePartValueItem {
|
||||
key: string;
|
||||
item: string;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("ha-clock-date-format-picker")
|
||||
export class HaClockDateFormatPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string[] | string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const valueItems = this._getValueItems(value);
|
||||
const sections = this._buildSections();
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
.sections=${this._getSectionHeaders(sections)}
|
||||
.getItems=${this._getItems(sections)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
valueItems,
|
||||
(entry: ClockDatePartValueItem) => entry.key,
|
||||
({ item, idx }) => this._renderValueChip(item, idx, sections)
|
||||
)}
|
||||
${
|
||||
this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._i18n.localize("ui.common.add")}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`
|
||||
}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _getValueItems = memoizeOne(
|
||||
(value: string[]): ClockDatePartValueItem[] => {
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return value.map((item, idx) => {
|
||||
const occurrence = occurrences.get(item) ?? 0;
|
||||
occurrences.set(item, occurrence + 1);
|
||||
|
||||
return {
|
||||
key: `${item}:${occurrence}`,
|
||||
item,
|
||||
idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _renderValueChip(
|
||||
item: string,
|
||||
idx: number,
|
||||
sections: ClockDatePartSectionData[]
|
||||
) {
|
||||
const label = this._getItemLabel(item, sections);
|
||||
const isValid = !!label;
|
||||
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label ?? item}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return !this.value ? [] : ensureArray(this.value);
|
||||
}
|
||||
|
||||
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
|
||||
value.length === 0 ? undefined : value
|
||||
);
|
||||
|
||||
private _buildSections(): ClockDatePartSectionData[] {
|
||||
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
|
||||
weekday: [],
|
||||
day: [],
|
||||
month: [],
|
||||
year: [],
|
||||
separator: [],
|
||||
};
|
||||
|
||||
const previewDate = new Date();
|
||||
const previewTimeZone = resolveTimeZone(
|
||||
this._i18n.locale.time_zone,
|
||||
this._hassConfig.config.time_zone
|
||||
);
|
||||
|
||||
CLOCK_CARD_DATE_PARTS.forEach((part) => {
|
||||
const section = getClockDatePartSection(part);
|
||||
const label =
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
|
||||
) ?? part;
|
||||
|
||||
const secondary =
|
||||
section === "separator"
|
||||
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
|
||||
: formatClockCardDate(
|
||||
previewDate,
|
||||
{ parts: [part] },
|
||||
this._i18n.locale.language,
|
||||
previewTimeZone
|
||||
);
|
||||
|
||||
itemsBySection[section].push({
|
||||
id: part,
|
||||
primary: label,
|
||||
secondary,
|
||||
sorting_label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
|
||||
id: section,
|
||||
title:
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
|
||||
) ?? section,
|
||||
items: itemsBySection[section],
|
||||
})).filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
private _getSectionHeaders(
|
||||
sections: ClockDatePartSectionData[]
|
||||
): { id: string; label: string }[] {
|
||||
return sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
}));
|
||||
}
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(sections: ClockDatePartSectionData[]) =>
|
||||
(
|
||||
searchString?: string,
|
||||
section?: string
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
const normalizedSearch = searchString?.trim().toLowerCase();
|
||||
|
||||
const filteredSections = sections
|
||||
.map((sectionData) => {
|
||||
if (!normalizedSearch) {
|
||||
return sectionData;
|
||||
}
|
||||
|
||||
return {
|
||||
...sectionData,
|
||||
items: sectionData.items.filter(
|
||||
(item) =>
|
||||
item.primary.toLowerCase().includes(normalizedSearch) ||
|
||||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
|
||||
item.id.toLowerCase().includes(normalizedSearch)
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((sectionData) => sectionData.items.length > 0);
|
||||
|
||||
if (section) {
|
||||
return (
|
||||
filteredSections.find((candidate) => candidate.id === section)
|
||||
?.items || []
|
||||
);
|
||||
}
|
||||
|
||||
const groupedItems: (PickerComboBoxItem | string)[] = [];
|
||||
|
||||
filteredSections.forEach((sectionData) => {
|
||||
groupedItems.push(sectionData.title, ...sectionData.items);
|
||||
});
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemLabel(
|
||||
value: string,
|
||||
sections: ClockDatePartSectionData[]
|
||||
): string | undefined {
|
||||
for (const section of sections) {
|
||||
const item = section.items.find((candidate) => candidate.id === value);
|
||||
|
||||
if (item) {
|
||||
if (section.id === "separator") {
|
||||
if (value === "separator-new-line") {
|
||||
return item.primary;
|
||||
}
|
||||
|
||||
return item.secondary ?? item.primary;
|
||||
}
|
||||
|
||||
return `${item.secondary} [${item.primary} ${section.title}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
return this._value[this._editIndex];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
|
||||
const value = this._value;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(idx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = [...this._value];
|
||||
value.splice(idx, 1);
|
||||
|
||||
if (this._editIndex !== undefined) {
|
||||
if (this._editIndex === idx) {
|
||||
this._editIndex = undefined;
|
||||
} else if (this._editIndex > idx) {
|
||||
this._editIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this._value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = value;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(value);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
|
||||
.container:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
transition:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
:host([disabled]) .container:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
|
||||
.container:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-clock-date-format-picker": HaClockDateFormatPicker;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { UiClockDateFormatSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-clock-date-format-picker";
|
||||
|
||||
@customElement("ha-selector-ui_clock_date_format")
|
||||
export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-clock-date-format-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-clock-date-format-picker>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const LOAD_ELEMENTS = {
|
||||
location: () => import("./ha-selector-location"),
|
||||
color_temp: () => import("./ha-selector-color-temp"),
|
||||
ui_action: () => import("./ha-selector-ui-action"),
|
||||
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ export interface HassioStats {
|
||||
network_tx: number;
|
||||
}
|
||||
|
||||
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
|
||||
response.data;
|
||||
|
||||
export const extractApiErrorMessage = (error: any): string =>
|
||||
typeof error === "object"
|
||||
? typeof error.body === "object"
|
||||
|
||||
+17
-47
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
type HassioDockerRegistries = Record<
|
||||
string,
|
||||
@@ -10,59 +7,32 @@ type HassioDockerRegistries = Record<
|
||||
|
||||
export const fetchHassioDockerRegistries = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioDockerRegistries> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
|
||||
"GET",
|
||||
"hassio/docker/registries"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioDockerRegistries> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const addHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
data: HassioDockerRegistries
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
|
||||
"POST",
|
||||
"hassio/docker/registries",
|
||||
data
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const removeHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
registry: string
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries/${registry}`,
|
||||
method: "delete",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"DELETE",
|
||||
`hassio/docker/registries/${registry}`
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries/${registry}`,
|
||||
method: "delete",
|
||||
});
|
||||
};
|
||||
|
||||
+12
-35
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHardwareAudioDevice {
|
||||
device?: string | null;
|
||||
@@ -30,38 +27,18 @@ export interface HassioHardwareInfo {
|
||||
|
||||
export const fetchHassioHardwareAudio = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHardwareAudioList> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/audio`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
|
||||
"GET",
|
||||
"hassio/hardware/audio"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioHardwareAudioList> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/audio`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioHardwareInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHardwareInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/info`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
|
||||
"GET",
|
||||
"hassio/hardware/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioHardwareInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/hardware/info`,
|
||||
method: "get",
|
||||
});
|
||||
|
||||
+70
-143
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHostInfo {
|
||||
agent_version: string;
|
||||
@@ -54,156 +51,86 @@ export interface HostDisksUsage {
|
||||
|
||||
export const fetchHassioHostInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHostInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
|
||||
"GET",
|
||||
"hassio/host/info"
|
||||
);
|
||||
return hassioApiResultExtractor(response);
|
||||
};
|
||||
): Promise<HassioHostInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioHassOsInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHassOSInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
): Promise<HassioHassOSInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
|
||||
"GET",
|
||||
"hassio/os/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
export const rebootHost = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/reboot",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const rebootHost = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/reboot",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
export const shutdownHost = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/shutdown",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
|
||||
};
|
||||
export const updateOS = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const shutdownHost = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/shutdown",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
export const configSyncOS = async (hass: HomeAssistant) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/config/sync",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
|
||||
};
|
||||
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/options",
|
||||
method: "post",
|
||||
data: options,
|
||||
});
|
||||
|
||||
export const updateOS = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
|
||||
};
|
||||
|
||||
export const configSyncOS = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/config/sync",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
|
||||
};
|
||||
|
||||
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/options",
|
||||
method: "post",
|
||||
data: options,
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/host/options",
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/move",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { device },
|
||||
});
|
||||
}
|
||||
|
||||
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
|
||||
};
|
||||
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/move",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { device },
|
||||
});
|
||||
|
||||
export const listDatadisks = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<DatadiskList> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS<DatadiskList>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/list",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
): Promise<DatadiskList> =>
|
||||
hass.callWS<DatadiskList>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/os/datadisk/list",
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HostDisksUsage>>(
|
||||
"GET",
|
||||
"hassio/host/disks/default/usage"
|
||||
)
|
||||
);
|
||||
};
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
|
||||
hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
});
|
||||
|
||||
+14
-33
@@ -1,9 +1,6 @@
|
||||
import { getCollection, type Connection } from "home-assistant-js-websocket";
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { supervisorApiWsRequest } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import type { CreateSessionResponse } from "./supervisor";
|
||||
|
||||
function setIngressCookie(session: string): string {
|
||||
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
|
||||
@@ -13,21 +10,14 @@ function setIngressCookie(session: string): string {
|
||||
}
|
||||
|
||||
export const createHassioSession = async (
|
||||
hass: HomeAssistant
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
): Promise<string> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
const wsResponse: { session: string } = await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/session",
|
||||
method: "post",
|
||||
});
|
||||
return setIngressCookie(wsResponse.session);
|
||||
}
|
||||
|
||||
const restResponse: { data: { session: string } } = await hass.callApi<
|
||||
HassioResponse<CreateSessionResponse>
|
||||
>("POST", "hassio/ingress/session");
|
||||
return setIngressCookie(restResponse.data.session);
|
||||
const wsResponse: { session: string } = await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/session",
|
||||
method: "post",
|
||||
});
|
||||
return setIngressCookie(wsResponse.session);
|
||||
};
|
||||
|
||||
export interface IngressPanelInfo {
|
||||
@@ -50,22 +40,13 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
|
||||
);
|
||||
|
||||
export const validateHassioSession = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
session: string
|
||||
): Promise<void> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/validate_session",
|
||||
method: "post",
|
||||
data: { session },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/ingress/validate_session",
|
||||
{ session }
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/ingress/validate_session",
|
||||
method: "post",
|
||||
data: { session },
|
||||
});
|
||||
};
|
||||
|
||||
+20
-52
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
interface IpConfiguration {
|
||||
address: string[];
|
||||
@@ -55,66 +52,37 @@ export interface NetworkInfo {
|
||||
|
||||
export const fetchNetworkInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<NetworkInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/network/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<NetworkInfo>>(
|
||||
"GET",
|
||||
"hassio/network/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<NetworkInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/network/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const updateNetworkInterface = async (
|
||||
hass: HomeAssistant,
|
||||
network_interface: string,
|
||||
options: Partial<NetworkInterface>
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/update`,
|
||||
method: "post",
|
||||
data: options,
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<NetworkInfo>>(
|
||||
"POST",
|
||||
`hassio/network/interface/${network_interface}/update`,
|
||||
options
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/update`,
|
||||
method: "post",
|
||||
data: options,
|
||||
timeout: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const accesspointScan = async (
|
||||
hass: HomeAssistant,
|
||||
network_interface: string
|
||||
): Promise<AccessPoints> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/accesspoints`,
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<AccessPoints>>(
|
||||
"GET",
|
||||
`hassio/network/interface/${network_interface}/accesspoints`
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<AccessPoints> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/network/interface/${network_interface}/accesspoints`,
|
||||
method: "get",
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const parseAddress = (address: string) => {
|
||||
const [ip, cidr] = address.split("/");
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant, TranslationDict } from "../../types";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioResolution {
|
||||
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
|
||||
@@ -12,19 +9,9 @@ export interface HassioResolution {
|
||||
|
||||
export const fetchHassioResolution = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioResolution> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/resolution/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioResolution>>(
|
||||
"GET",
|
||||
"hassio/resolution/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioResolution> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/resolution/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import type { SupervisorArch } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import { hassioApiResultExtractor } from "./common";
|
||||
|
||||
export interface HassioHomeAssistantInfo {
|
||||
arch: SupervisorArch;
|
||||
@@ -77,10 +75,6 @@ export type HassioPanelInfo = PanelInfo<
|
||||
}
|
||||
>;
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
session: string;
|
||||
}
|
||||
|
||||
export interface SupervisorOptions {
|
||||
channel?: "beta" | "dev" | "stable";
|
||||
diagnostics?: boolean;
|
||||
@@ -88,99 +82,57 @@ export interface SupervisorOptions {
|
||||
}
|
||||
|
||||
export const reloadSupervisor = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/reload",
|
||||
method: "post",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/reload",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
export const restartSupervisor = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/restart",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/restart",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSupervisor = async (hass: HomeAssistant) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchHassioHomeAssistantInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioHomeAssistantInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
|
||||
"GET",
|
||||
"hassio/core/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioHomeAssistantInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioSupervisorInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioSupervisorInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
|
||||
"GET",
|
||||
"hassio/supervisor/info"
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<HassioSupervisorInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioInfo = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioInfo> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
return hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
|
||||
);
|
||||
};
|
||||
): Promise<HassioInfo> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/info",
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioBoots = async (hass: HomeAssistant) =>
|
||||
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
@@ -263,21 +215,12 @@ export const setSupervisorOption = async (
|
||||
hass: HomeAssistant,
|
||||
data: SupervisorOptions
|
||||
) => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/options",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>(
|
||||
"POST",
|
||||
"hassio/supervisor/options",
|
||||
data
|
||||
);
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/supervisor/options",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface CustomPanelConfig {
|
||||
js_url?: string;
|
||||
module_url?: string;
|
||||
html_url?: string;
|
||||
// When true, the panel takes care of the safe-area insets itself (e.g. it
|
||||
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
|
||||
// purpose). Home Assistant then skips adding its own safe-area padding around
|
||||
// the panel to avoid doubling the insets.
|
||||
handle_safe_area?: boolean;
|
||||
}
|
||||
|
||||
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
|
||||
|
||||
@@ -80,6 +80,7 @@ export type Selector =
|
||||
| TTSVoiceSelector
|
||||
| SerialPortSelector
|
||||
| UiActionSelector
|
||||
| UiClockDateFormatSelector
|
||||
| UiColorSelector
|
||||
| UiStateContentSelector
|
||||
| UiTimeFormatSelector
|
||||
@@ -577,6 +578,10 @@ export interface UiActionSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface UiClockDateFormatSelector {
|
||||
ui_clock_date_format: {} | null;
|
||||
}
|
||||
|
||||
export interface UiColorExtraOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "../hassio/common";
|
||||
import { hassioApiResultExtractor } from "../hassio/common";
|
||||
|
||||
export interface SupervisorApiCallOptions {
|
||||
method?: "get" | "post" | "delete";
|
||||
@@ -13,23 +10,11 @@ export const supervisorApiCall = async <T>(
|
||||
hass: HomeAssistant,
|
||||
endpoint: string,
|
||||
options?: SupervisorApiCallOptions
|
||||
): Promise<T> => {
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
// Websockets was added in 2021.2.4
|
||||
return hass.callWS<T>({
|
||||
type: "supervisor/api",
|
||||
endpoint,
|
||||
method: options?.method || "get",
|
||||
timeout: options?.timeout ?? null,
|
||||
data: options?.data,
|
||||
});
|
||||
}
|
||||
return hassioApiResultExtractor(
|
||||
await hass.callApi<HassioResponse<T>>(
|
||||
// @ts-ignore
|
||||
(options.method || "get").toUpperCase(),
|
||||
`hassio${endpoint}`,
|
||||
options?.data
|
||||
)
|
||||
);
|
||||
};
|
||||
): Promise<T> =>
|
||||
hass.callWS<T>({
|
||||
type: "supervisor/api",
|
||||
endpoint,
|
||||
method: options?.method || "get",
|
||||
timeout: options?.timeout ?? null,
|
||||
data: options?.data,
|
||||
});
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassioResponse } from "../hassio/common";
|
||||
|
||||
export const restartCore = async (hass: HomeAssistant) => {
|
||||
await hass.callService("homeassistant", "restart");
|
||||
};
|
||||
|
||||
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
|
||||
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/core/update",
|
||||
method: "post",
|
||||
timeout: null,
|
||||
data: { backup },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
|
||||
backup,
|
||||
await hass.callWS({
|
||||
type: "hassio/update/core",
|
||||
backup: backup,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -322,6 +322,8 @@ export class DialogHttpPendingConfig
|
||||
ul {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
padding-inline-end: initial;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
li {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,7 +33,12 @@ import {
|
||||
TimeZone,
|
||||
} from "../data/translation";
|
||||
import { translationMetadata } from "../resources/translations-metadata";
|
||||
import type { HomeAssistant, Resources, ValuePart } from "../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
Resources,
|
||||
ThemeSettings,
|
||||
ValuePart,
|
||||
} from "../types";
|
||||
import { getLocalLanguage, getTranslation } from "../util/common-translation";
|
||||
import { demoConfig } from "./demo_config";
|
||||
import { demoPanels } from "./demo_panels";
|
||||
@@ -83,7 +88,10 @@ export interface MockHomeAssistant extends HomeAssistant {
|
||||
loader: () => Promise<unknown>
|
||||
);
|
||||
mockEvent(event);
|
||||
mockTheme(theme: Record<string, string> | null);
|
||||
mockTheme(
|
||||
theme: Record<string, string> | null,
|
||||
selectedTheme?: ThemeSettings
|
||||
);
|
||||
formatEntityState(stateObj: HassEntity, state?: string): string;
|
||||
formatEntityStateToParts(stateObj: HassEntity, state?: string): ValuePart[];
|
||||
formatEntityAttributeValue(
|
||||
@@ -503,25 +511,35 @@ export const provideHass = (
|
||||
},
|
||||
mockAPI,
|
||||
mockEvent(event) {
|
||||
(eventListeners[event] || []).forEach((fn) => fn(event));
|
||||
(eventListeners[event] || []).forEach((fn) => {
|
||||
fn(event);
|
||||
});
|
||||
},
|
||||
mockTheme(theme) {
|
||||
mockTheme(theme, selectedTheme) {
|
||||
invalidateThemeCache();
|
||||
selectedTheme ??= {
|
||||
theme: theme ? "fake-data" : "default",
|
||||
dark: false,
|
||||
};
|
||||
const themeName = selectedTheme.theme;
|
||||
const darkMode =
|
||||
selectedTheme.dark ??
|
||||
matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
hass().updateHass({
|
||||
selectedTheme: { theme: theme ? "mock" : "default", dark: false },
|
||||
selectedTheme,
|
||||
themes: {
|
||||
...hass().themes,
|
||||
themes: {
|
||||
mock: theme as any,
|
||||
},
|
||||
darkMode,
|
||||
theme: themeName,
|
||||
themes: theme ? { [themeName]: theme as any } : {},
|
||||
},
|
||||
});
|
||||
const { themes, selectedTheme } = hass();
|
||||
const { themes } = hass();
|
||||
applyThemesOnElement(
|
||||
document.documentElement,
|
||||
themes,
|
||||
selectedTheme!.theme,
|
||||
{ dark: false },
|
||||
themeName,
|
||||
{ ...selectedTheme, dark: darkMode },
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
@@ -53,6 +53,11 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
@state() private _iframeLoaded = false;
|
||||
|
||||
// Set when the addon signals (via subscribe-properties) that it handles the
|
||||
// safe-area insets itself. We then stop padding the iframe and forward the
|
||||
// inset values so the addon can draw into the safe area.
|
||||
@state() private _handleSafeArea = false;
|
||||
|
||||
private _enabledKioskMode = false;
|
||||
|
||||
private _sessionKeepAlive?: number;
|
||||
@@ -88,11 +93,13 @@ class HaPanelApp extends LitElement {
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this._handleIframeMessage);
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this._handleIframeMessage);
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
@@ -135,6 +142,7 @@ class HaPanelApp extends LitElement {
|
||||
class=${classMap({
|
||||
loaded: this._iframeLoaded,
|
||||
"kiosk-mode": this._kioskMode,
|
||||
"handle-safe-area": this._handleSafeArea,
|
||||
})}
|
||||
title=${this._addon.name}
|
||||
src=${this._addon.ingress_url!}
|
||||
@@ -179,6 +187,7 @@ class HaPanelApp extends LitElement {
|
||||
this._enabledKioskMode = false;
|
||||
}
|
||||
this._iframeSubscribeUpdates = false;
|
||||
this._handleSafeArea = false;
|
||||
this._autoRetryUntil = undefined;
|
||||
this._fetchData(addon);
|
||||
}
|
||||
@@ -416,6 +425,9 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
case "home-assistant/subscribe-properties":
|
||||
this._iframeSubscribeUpdates = true;
|
||||
// An addon can opt out of the container padding and take care of the
|
||||
// safe area itself; we then forward the inset values below.
|
||||
this._handleSafeArea = !!data.handleSafeArea;
|
||||
this._sendPropertiesToIframe();
|
||||
if (data.kioskMode && !this.hass.kioskMode) {
|
||||
this._enabledKioskMode = true;
|
||||
@@ -425,6 +437,7 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
case "home-assistant/unsubscribe-properties":
|
||||
this._iframeSubscribeUpdates = false;
|
||||
this._handleSafeArea = false;
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
this._enabledKioskMode = false;
|
||||
@@ -433,16 +446,38 @@ class HaPanelApp extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Safe-area insets can change on orientation change; keep a subscribing
|
||||
// addon in sync.
|
||||
private _handleResize = () => {
|
||||
if (this._iframeSubscribeUpdates) {
|
||||
this._sendPropertiesToIframe();
|
||||
}
|
||||
};
|
||||
|
||||
private _sendPropertiesToIframe() {
|
||||
if (!this._iframeRef.value?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(this);
|
||||
this._iframeRef.value.contentWindow.postMessage(
|
||||
{
|
||||
type: "home-assistant/properties",
|
||||
narrow: this.narrow,
|
||||
route: this._computeRouteTail(this.route),
|
||||
// Resolved insets so an addon that handles the safe area itself can
|
||||
// apply them. Vertical uses the raw insets, horizontal the content
|
||||
// variables (the docked sidebar already absorbs its side).
|
||||
safeAreaInsets: {
|
||||
top: styles.getPropertyValue("--safe-area-inset-top").trim(),
|
||||
right:
|
||||
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-right").trim(),
|
||||
bottom: styles.getPropertyValue("--safe-area-inset-bottom").trim(),
|
||||
left:
|
||||
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-left").trim(),
|
||||
},
|
||||
},
|
||||
"*"
|
||||
);
|
||||
@@ -462,30 +497,38 @@ class HaPanelApp extends LitElement {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Keep the addon iframe clear of the device safe areas. CSS variables don't
|
||||
cross the iframe boundary, so this padding on the iframe element is the
|
||||
only way to inset the embedded document. Vertical uses the raw insets;
|
||||
horizontal uses the content variables, since the docked sidebar already
|
||||
absorbs the inset on its side (avoids doubling it). */
|
||||
iframe {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background-color: var(--primary-background-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease;
|
||||
padding: var(--safe-area-inset-top)
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
|
||||
var(--safe-area-inset-bottom)
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left));
|
||||
}
|
||||
|
||||
iframe.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The addon takes care of the safe area itself (it receives the insets via
|
||||
postMessage), so drop the container padding to let it draw full-bleed. */
|
||||
iframe.handle-safe-area {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* When the header is shown it already covers the top inset. */
|
||||
.header + iframe {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
:host([narrow]) iframe {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
height: calc(100% - var(--safe-area-inset-top, 0px));
|
||||
}
|
||||
|
||||
:host([narrow]) .header + iframe {
|
||||
padding-top: 0;
|
||||
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
|
||||
}
|
||||
@@ -494,8 +537,17 @@ class HaPanelApp extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-l);
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
height: calc(40px + var(--safe-area-inset-top, 0px));
|
||||
padding: var(--safe-area-inset-top)
|
||||
calc(
|
||||
16px +
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
|
||||
)
|
||||
0
|
||||
calc(
|
||||
16px +
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left))
|
||||
);
|
||||
pointer-events: none;
|
||||
background-color: var(--app-header-background-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
@@ -505,11 +557,6 @@ class HaPanelApp extends LitElement {
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
:host([narrow]) .header {
|
||||
height: calc(40px + var(--safe-area-inset-top, 0px));
|
||||
padding-top: var(--safe-area-inset-top, 0);
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -386,9 +386,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
this._dirtyState?.setState(
|
||||
{
|
||||
name: this._name.trim() || null,
|
||||
icon: this._icon.trim() || null,
|
||||
entityId: this._entityId.trim(),
|
||||
name: this._name || null,
|
||||
icon: this._icon || null,
|
||||
entityId: this._entityId,
|
||||
areaId: this._areaId ?? null,
|
||||
labels: this._labels ?? [],
|
||||
deviceClass: this._deviceClass,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -36,6 +36,11 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
|
||||
private _wasDisconnected = false;
|
||||
|
||||
// Set for embedded-iframe panels that opt out of the container padding via
|
||||
// `handle_safe_area`; we then inject the resolved insets into the (same
|
||||
// origin) iframe document so the panel can consume `--safe-area-inset-*`.
|
||||
private _syncSafeArea = false;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -54,10 +59,14 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
});
|
||||
this._setProperties = setProperties;
|
||||
this.querySelector("iframe")?.classList.add("loaded");
|
||||
// registerIframe also fires on the iframe's `pageshow`, so this re-applies
|
||||
// the insets after an internal reload.
|
||||
this._syncSafeAreaToIframe();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
// Only rebuild when reattached after a real disconnect (the 5-minute
|
||||
// suspendWhenHidden timer in partial-panel-resolver). On first mount,
|
||||
// update() handles creation via the panel-changed branch, so calling
|
||||
@@ -70,10 +79,51 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
this._wasDisconnected = true;
|
||||
this._cleanupPanel();
|
||||
}
|
||||
|
||||
// Safe-area insets can change on orientation change; keep the embedded
|
||||
// document in sync.
|
||||
private _handleResize = () => {
|
||||
this._syncSafeAreaToIframe();
|
||||
};
|
||||
|
||||
private _syncSafeAreaToIframe() {
|
||||
if (!this._syncSafeArea) {
|
||||
return;
|
||||
}
|
||||
const root =
|
||||
this.querySelector("iframe")?.contentWindow?.document?.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
// CSS variables don't cross the iframe boundary, so copy the resolved
|
||||
// values onto the (same-origin) iframe document. Vertical uses the raw
|
||||
// insets, horizontal the content variables (the sidebar already absorbs
|
||||
// its side).
|
||||
const styles = getComputedStyle(this);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-top",
|
||||
styles.getPropertyValue("--safe-area-inset-top").trim()
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-bottom",
|
||||
styles.getPropertyValue("--safe-area-inset-bottom").trim()
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-left",
|
||||
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-left").trim()
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-right",
|
||||
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
|
||||
styles.getPropertyValue("--safe-area-inset-right").trim()
|
||||
);
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
super.update(changedProps);
|
||||
if (changedProps.has("panel")) {
|
||||
@@ -101,6 +151,7 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
private _cleanupPanel() {
|
||||
delete window.customPanel;
|
||||
this._setProperties = undefined;
|
||||
this._syncSafeArea = false;
|
||||
while (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
@@ -112,6 +163,30 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
const config = panel.config!._panel_custom;
|
||||
const panelUrl = getUrl(config);
|
||||
|
||||
// Keep the panel content clear of the device safe areas (status bar, home
|
||||
// indicator, notch). Panels rendered in light DOM inherit the
|
||||
// `--safe-area-inset-*` variables but most don't consume them, so we apply
|
||||
// the padding on the container as a safe baseline. The embedded-iframe
|
||||
// branch below applies the equivalent padding on the iframe instead. Panels
|
||||
// that manage the safe area themselves can opt out via `handle_safe_area`.
|
||||
const applySafeArea = !config.handle_safe_area;
|
||||
// For opted-out embedded-iframe panels we inject the insets into the iframe
|
||||
// document instead of padding it (see _syncSafeAreaToIframe).
|
||||
this._syncSafeArea = !applySafeArea && !!config.embed_iframe;
|
||||
if (applySafeArea && !config.embed_iframe) {
|
||||
this.style.display = "block";
|
||||
this.style.boxSizing = "border-box";
|
||||
// Vertical insets aren't absorbed by any chrome around the panel, so use
|
||||
// the raw insets. Horizontal uses the (physical) content variables, since
|
||||
// the sidebar already absorbs the inset on its side (avoids doubling it).
|
||||
this.style.paddingTop = "var(--safe-area-inset-top)";
|
||||
this.style.paddingBottom = "var(--safe-area-inset-bottom)";
|
||||
this.style.paddingLeft =
|
||||
"var(--safe-area-content-inset-left, var(--safe-area-inset-left))";
|
||||
this.style.paddingRight =
|
||||
"var(--safe-area-content-inset-right, var(--safe-area-inset-right))";
|
||||
}
|
||||
|
||||
const tempA = document.createElement("a");
|
||||
tempA.href = panelUrl.url;
|
||||
|
||||
@@ -168,14 +243,26 @@ export class HaPanelCustom extends ReactiveElement {
|
||||
|
||||
window.customPanel = this;
|
||||
const titleAttr = this.panel.title ? `title="${this.panel.title}"` : "";
|
||||
// Pad the iframe (not the host) with the safe-area insets so the embedded
|
||||
// document stays clear of the device safe areas. CSS variables don't cross
|
||||
// the iframe boundary, so this container padding is the baseline; panels
|
||||
// that handle it themselves opt out via `handle_safe_area`.
|
||||
const safeAreaPadding = applySafeArea
|
||||
? `padding: var(--safe-area-inset-top)
|
||||
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
|
||||
var(--safe-area-inset-bottom)
|
||||
var(--safe-area-content-inset-left, var(--safe-area-inset-left));`
|
||||
: "";
|
||||
this.innerHTML = `
|
||||
<style>
|
||||
iframe {
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: block;
|
||||
${safeAreaPadding}
|
||||
background-color: var(--primary-background-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease;
|
||||
|
||||
@@ -128,19 +128,13 @@ class HaPanelHistory extends LitElement {
|
||||
<h1 class="page-title" slot="title">
|
||||
${this.hass.localize("panel.history")}
|
||||
</h1>
|
||||
${
|
||||
entitiesSelected
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._removeAll}
|
||||
.disabled=${this._isLoading}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._removeAll}
|
||||
.disabled=${this._isLoading || !entitiesSelected}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
|
||||
@@ -49,10 +49,13 @@ class HaPanelIframe extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* Fill hass-subpage's content box, which already excludes the safe-area
|
||||
insets (see hass-subpage .content), instead of positioning absolutely
|
||||
and spilling into the bottom/side insets. */
|
||||
iframe {
|
||||
border: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiRefresh } from "@mdi/js";
|
||||
import { mdiFilterRemove, mdiRefresh } from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
extractSearchParamsObject,
|
||||
removeSearchParam,
|
||||
} from "../../common/url/search-params";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-target-picker";
|
||||
@@ -27,6 +28,11 @@ import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook";
|
||||
|
||||
interface LogbookState {
|
||||
time: { range: [Date, Date] };
|
||||
targetPickerValue: HassServiceTarget;
|
||||
}
|
||||
|
||||
@customElement("ha-panel-logbook")
|
||||
export class HaPanelLogbook extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -54,14 +60,7 @@ export class HaPanelLogbook extends LitElement {
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
const start = new Date();
|
||||
start.setHours(start.getHours() - 1, 0, 0, 0);
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(end.getHours() + 2, 0, 0, 0);
|
||||
|
||||
this._time = { range: [start, end] };
|
||||
this._time = this._defaultState.time;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -71,6 +70,13 @@ export class HaPanelLogbook extends LitElement {
|
||||
.backButton=${!!this._showBack}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.logbook")}</div>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._resetLogbook}
|
||||
.disabled=${this._isDefaultState()}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.common.reset")}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._refreshLogbook}
|
||||
@@ -230,6 +236,34 @@ export class HaPanelLogbook extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private get _defaultState(): LogbookState {
|
||||
const start = new Date();
|
||||
start.setHours(start.getHours() - 1, 0, 0, 0);
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(end.getHours() + 2, 0, 0, 0);
|
||||
|
||||
return {
|
||||
time: { range: [start, end] },
|
||||
targetPickerValue: {},
|
||||
};
|
||||
}
|
||||
|
||||
private _isDefaultState(): boolean {
|
||||
return deepEqual(
|
||||
{ time: this._time, targetPickerValue: this._targetPickerValue },
|
||||
this._defaultState
|
||||
);
|
||||
}
|
||||
|
||||
private _resetLogbook() {
|
||||
const defaultState = this._defaultState;
|
||||
this._time = defaultState.time;
|
||||
this._targetPickerValue = defaultState.targetPickerValue;
|
||||
this._storedTargetPickerValue = undefined;
|
||||
navigate("/logbook", { replace: true });
|
||||
}
|
||||
|
||||
private _refreshLogbook() {
|
||||
this.shadowRoot!.querySelector("ha-logbook")?.refresh();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ClockCardConfig, ClockCardDatePart } from "../types";
|
||||
|
||||
type ClockCardSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
|
||||
|
||||
/**
|
||||
* Normalized date configuration used by clock card renderers.
|
||||
*/
|
||||
interface ClockCardDateConfig {
|
||||
parts: ClockCardDatePart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the locale and time zone for a clock card from `hass` and the
|
||||
* card's configuration. Applies the optional `time_format` override to the
|
||||
* locale and falls back to the user's preferred time zone.
|
||||
*/
|
||||
export const resolveClockCardLocale = (
|
||||
hass: HomeAssistant,
|
||||
config: Pick<ClockCardConfig, "time_format" | "time_zone">
|
||||
): { locale: HomeAssistant["locale"]; timeZone: string } => {
|
||||
const locale = config.time_format
|
||||
? { ...hass.locale, time_format: config.time_format }
|
||||
: hass.locale;
|
||||
|
||||
const timeZone =
|
||||
config.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
|
||||
|
||||
return { locale, timeZone };
|
||||
};
|
||||
|
||||
/**
|
||||
* All selectable date tokens exposed by the clock card editor.
|
||||
*/
|
||||
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
|
||||
"weekday-short",
|
||||
"weekday-long",
|
||||
"day-numeric",
|
||||
"day-2-digit",
|
||||
"month-short",
|
||||
"month-long",
|
||||
"month-numeric",
|
||||
"month-2-digit",
|
||||
"year-2-digit",
|
||||
"year-numeric",
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
];
|
||||
|
||||
const DATE_PART_OPTIONS: Record<
|
||||
ClockCardValuePart,
|
||||
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
|
||||
> = {
|
||||
"weekday-short": { weekday: "short" },
|
||||
"weekday-long": { weekday: "long" },
|
||||
"day-numeric": { day: "numeric" },
|
||||
"day-2-digit": { day: "2-digit" },
|
||||
"month-short": { month: "short" },
|
||||
"month-long": { month: "long" },
|
||||
"month-numeric": { month: "numeric" },
|
||||
"month-2-digit": { month: "2-digit" },
|
||||
"year-2-digit": { year: "2-digit" },
|
||||
"year-numeric": { year: "numeric" },
|
||||
};
|
||||
|
||||
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "\n",
|
||||
};
|
||||
|
||||
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
]);
|
||||
|
||||
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
|
||||
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
|
||||
|
||||
const isDateSeparatorPart = (
|
||||
part: ClockCardDatePart
|
||||
): part is ClockCardSeparatorPart =>
|
||||
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
|
||||
|
||||
/**
|
||||
* Returns a reusable formatter for a specific date token.
|
||||
*/
|
||||
const getDatePartFormatter = (
|
||||
part: ClockCardValuePart,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
): Intl.DateTimeFormat => {
|
||||
const cacheKey = `${language}|${timeZone || ""}|${part}`;
|
||||
const cached = DATE_PART_FORMATTERS.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(language, {
|
||||
...DATE_PART_OPTIONS[part],
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
});
|
||||
|
||||
DATE_PART_FORMATTERS.set(cacheKey, formatter);
|
||||
|
||||
return formatter;
|
||||
};
|
||||
|
||||
const formatDatePart = (
|
||||
part: ClockCardValuePart,
|
||||
date: Date,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
) => getDatePartFormatter(part, language, timeZone).format(date);
|
||||
|
||||
/**
|
||||
* Applies a single date token to Intl.DateTimeFormat options.
|
||||
*/
|
||||
const applyDatePartOption = (
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
part: ClockCardDatePart
|
||||
) => {
|
||||
if (isDateSeparatorPart(part)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const partOptions = DATE_PART_OPTIONS[part];
|
||||
|
||||
if (partOptions.weekday) {
|
||||
options.weekday = partOptions.weekday;
|
||||
}
|
||||
|
||||
if (partOptions.day) {
|
||||
options.day = partOptions.day;
|
||||
}
|
||||
|
||||
if (partOptions.month) {
|
||||
options.month = partOptions.month;
|
||||
}
|
||||
|
||||
if (partOptions.year) {
|
||||
options.year = partOptions.year;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes configured date tokens while preserving their literal order.
|
||||
*/
|
||||
const normalizeDateParts = (
|
||||
parts: ClockCardConfig["date_format"]
|
||||
): ClockCardDatePart[] =>
|
||||
parts?.filter((part): part is ClockCardDatePart =>
|
||||
isClockCardDatePart(part)
|
||||
) || [];
|
||||
|
||||
/**
|
||||
* Returns a normalized date config from a card configuration object.
|
||||
*/
|
||||
export const getClockCardDateConfig = (
|
||||
config?: Pick<ClockCardConfig, "date_format">
|
||||
): ClockCardDateConfig => ({
|
||||
parts: normalizeDateParts(config?.date_format),
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks whether the clock configuration resolves to any visible date output.
|
||||
*/
|
||||
export const hasClockCardDate = (
|
||||
config?: Pick<ClockCardConfig, "date_format">
|
||||
): boolean => getClockCardDateConfig(config).parts.length > 0;
|
||||
|
||||
/**
|
||||
* Converts normalized date tokens into Intl.DateTimeFormat options.
|
||||
*
|
||||
* Separator tokens are ignored. If multiple tokens target the same Intl field,
|
||||
* the last one wins.
|
||||
*/
|
||||
export const getClockCardDateTimeFormatOptions = (
|
||||
dateConfig: ClockCardDateConfig
|
||||
): Intl.DateTimeFormatOptions => {
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
|
||||
dateConfig.parts.forEach((part) => {
|
||||
applyDatePartOption(options, part);
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the final date string from literal date tokens.
|
||||
*
|
||||
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
|
||||
* always rendered literally. A default space is only inserted between adjacent
|
||||
* value tokens.
|
||||
*/
|
||||
export const formatClockCardDate = (
|
||||
date: Date,
|
||||
dateConfig: ClockCardDateConfig,
|
||||
language: string,
|
||||
timeZone?: string
|
||||
): string => {
|
||||
let result = "";
|
||||
let previousRenderedPartWasValue = false;
|
||||
|
||||
dateConfig.parts.forEach((part) => {
|
||||
if (isDateSeparatorPart(part)) {
|
||||
result += DATE_SEPARATORS[part];
|
||||
previousRenderedPartWasValue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const value = formatDatePart(part, date, language, timeZone);
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousRenderedPartWasValue) {
|
||||
result += " ";
|
||||
}
|
||||
|
||||
result += value;
|
||||
previousRenderedPartWasValue = true;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -2,9 +2,17 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import "../../../../components/ha-marquee-text";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ClockCardConfig } from "../types";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
hasClockCardDate,
|
||||
resolveClockCardLocale,
|
||||
} from "./clock-date-format";
|
||||
|
||||
function romanize12HourClock(num: number) {
|
||||
const numerals = [
|
||||
@@ -26,6 +34,11 @@ function romanize12HourClock(num: number) {
|
||||
return numerals[num];
|
||||
}
|
||||
|
||||
const DATE_UPDATE_INTERVAL = 60_000;
|
||||
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
|
||||
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
|
||||
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
|
||||
|
||||
@customElement("hui-clock-card-analog")
|
||||
export class HuiClockCardAnalog extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -40,42 +53,18 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
|
||||
@state() private _secondOffsetSec?: number;
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@state() private _date?: string;
|
||||
|
||||
let locale = this.hass.locale;
|
||||
if (this.config.time_format) {
|
||||
locale = { ...locale, time_format: this.config.time_format };
|
||||
}
|
||||
private _dateInterval?: number;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h12",
|
||||
timeZone:
|
||||
this.config.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
|
||||
});
|
||||
private _timeZone?: string;
|
||||
|
||||
this._computeOffsets();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (!oldHass || oldHass.locale !== this.hass?.locale) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
private _language?: string;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
||||
this._computeOffsets();
|
||||
this._initDate();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -84,18 +73,80 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
this._stopDateTick();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has("config") || changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (
|
||||
changedProps.has("config") ||
|
||||
!oldHass ||
|
||||
oldHass.locale !== this.hass?.locale
|
||||
) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
this._computeOffsets();
|
||||
this._updateDate();
|
||||
}
|
||||
};
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
this._stopDateTick();
|
||||
this._date = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
|
||||
|
||||
this._language = this.hass.locale.language;
|
||||
this._timeZone = timeZone;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h12",
|
||||
timeZone,
|
||||
});
|
||||
|
||||
this._computeOffsets();
|
||||
this._updateDate();
|
||||
|
||||
if (this.isConnected && hasClockCardDate(this.config)) {
|
||||
this._startDateTick();
|
||||
} else {
|
||||
this._stopDateTick();
|
||||
}
|
||||
}
|
||||
|
||||
private _startDateTick() {
|
||||
this._stopDateTick();
|
||||
this._dateInterval = window.setInterval(
|
||||
() => this._updateDate(),
|
||||
DATE_UPDATE_INTERVAL
|
||||
);
|
||||
}
|
||||
|
||||
private _stopDateTick() {
|
||||
if (this._dateInterval) {
|
||||
clearInterval(this._dateInterval);
|
||||
this._dateInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeOffsets() {
|
||||
if (!this._dateTimeFormat) return;
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts();
|
||||
const date = new Date();
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts(date);
|
||||
const hourStr = parts.find((p) => p.type === "hour")?.value;
|
||||
const minuteStr = parts.find((p) => p.type === "minute")?.value;
|
||||
const secondStr = parts.find((p) => p.type === "second")?.value;
|
||||
@@ -103,7 +154,7 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
const hour = hourStr ? parseInt(hourStr, 10) : 0;
|
||||
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
|
||||
const second = secondStr ? parseInt(secondStr, 10) : 0;
|
||||
const ms = new Date().getMilliseconds();
|
||||
const ms = date.getMilliseconds();
|
||||
const secondsWithMs = second + ms / 1000;
|
||||
|
||||
const hour12 = hour % 12;
|
||||
@@ -113,16 +164,45 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
|
||||
}
|
||||
|
||||
private _updateDate() {
|
||||
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
|
||||
this._date = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const dateConfig = getClockCardDateConfig(this.config);
|
||||
this._date = formatClockCardDate(
|
||||
new Date(),
|
||||
dateConfig,
|
||||
this._language,
|
||||
this._timeZone
|
||||
);
|
||||
}
|
||||
|
||||
private _computeClock = memoizeOne((config: ClockCardConfig) => {
|
||||
const faceParts = config.face_style?.split("_");
|
||||
const dateConfig = getClockCardDateConfig(config);
|
||||
const showDate = hasClockCardDate(config);
|
||||
const isLongDate =
|
||||
dateConfig.parts.includes("month-long") ||
|
||||
dateConfig.parts.includes("weekday-long");
|
||||
|
||||
return {
|
||||
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
|
||||
isNumbers: faceParts?.includes("numbers") ?? false,
|
||||
isRoman: faceParts?.includes("roman") ?? false,
|
||||
isUpright: faceParts?.includes("upright") ?? false,
|
||||
showDate,
|
||||
isLongDate,
|
||||
};
|
||||
});
|
||||
|
||||
render() {
|
||||
if (!this.config) return nothing;
|
||||
|
||||
const sizeClass = this.config.clock_size
|
||||
? `size-${this.config.clock_size}`
|
||||
: "";
|
||||
|
||||
const isNumbers = this.config?.face_style?.startsWith("numbers");
|
||||
const isRoman = this.config?.face_style?.startsWith("roman");
|
||||
const isUpright = this.config?.face_style?.endsWith("upright");
|
||||
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
|
||||
this._computeClock(this.config);
|
||||
const dateLines = this._date?.split("\n") ?? [];
|
||||
|
||||
const indicator = (number?: number) => html`
|
||||
<div
|
||||
@@ -168,14 +248,14 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
>
|
||||
${
|
||||
this.config.ticks === "quarter"
|
||||
? Array.from({ length: 4 }, (_, i) => i).map(
|
||||
? QUARTER_TICKS.map(
|
||||
(i) =>
|
||||
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick hour"
|
||||
style=${`--tick-rotation: ${i * 90}deg;`}
|
||||
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
|
||||
>
|
||||
${indicator([12, 3, 6, 9][i])}
|
||||
</div>
|
||||
@@ -183,28 +263,30 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
)
|
||||
: !this.config.ticks || // Default to hour ticks
|
||||
this.config.ticks === "hour"
|
||||
? Array.from({ length: 12 }, (_, i) => i).map(
|
||||
? HOUR_TICKS.map(
|
||||
(i) =>
|
||||
// 12 ticks (1-12)
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick hour"
|
||||
style=${`--tick-rotation: ${i * 30}deg;`}
|
||||
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
|
||||
>
|
||||
${indicator(((i + 11) % 12) + 1)}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
: this.config.ticks === "minute"
|
||||
? Array.from({ length: 60 }, (_, i) => i).map(
|
||||
? MINUTE_TICKS.map(
|
||||
(i) =>
|
||||
// 60 ticks (1-60)
|
||||
html`
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
|
||||
style=${`--tick-rotation: ${i * 6}deg;`}
|
||||
style=${styleMap({
|
||||
"--tick-rotation": `${i * 6}deg`,
|
||||
})}
|
||||
>
|
||||
${
|
||||
i % 5 === 0
|
||||
@@ -216,14 +298,43 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDate
|
||||
? html`<div
|
||||
class=${classMap({
|
||||
date: true,
|
||||
[sizeClass]: true,
|
||||
"long-date": isLongDate,
|
||||
})}
|
||||
>
|
||||
${dateLines.map(
|
||||
(line) => html`
|
||||
<ha-marquee-text
|
||||
class="date-line"
|
||||
speed="5"
|
||||
pause-duration="1500"
|
||||
pause-on-hover
|
||||
>
|
||||
${line}
|
||||
</ha-marquee-text>
|
||||
`
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="center-dot"></div>
|
||||
<div
|
||||
class="hand hour"
|
||||
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
|
||||
})}
|
||||
></div>
|
||||
<div
|
||||
class="hand minute"
|
||||
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
|
||||
})}
|
||||
></div>
|
||||
${
|
||||
this.config.show_seconds
|
||||
@@ -233,11 +344,13 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
second: true,
|
||||
step: this.config.seconds_motion === "tick",
|
||||
})}
|
||||
style=${`animation-delay: -${
|
||||
(this.config.seconds_motion === "tick"
|
||||
? Math.floor(this._secondOffsetSec ?? 0)
|
||||
: (this._secondOffsetSec ?? 0)) as number
|
||||
}s;`}
|
||||
style=${styleMap({
|
||||
"animation-delay": `-${
|
||||
this.config.seconds_motion === "tick"
|
||||
? Math.floor(this._secondOffsetSec ?? 0)
|
||||
: (this._secondOffsetSec ?? 0)
|
||||
}s`,
|
||||
})}
|
||||
></div>`
|
||||
: nothing
|
||||
}
|
||||
@@ -417,6 +530,42 @@ export class HuiClockCardAnalog extends LitElement {
|
||||
transform: translate(-50%, 0) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
top: 68%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-line {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date.long-date:not(.size-medium):not(.size-large) {
|
||||
top: 66%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
line-height: 1;
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.date.size-medium {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
|
||||
.date.size-large {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../components/ha-marquee-text";
|
||||
import type { ClockCardConfig } from "../types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { useAmPm } from "../../../../common/datetime/use_am_pm";
|
||||
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
|
||||
import {
|
||||
formatClockCardDate,
|
||||
getClockCardDateConfig,
|
||||
hasClockCardDate,
|
||||
resolveClockCardLocale,
|
||||
} from "./clock-date-format";
|
||||
|
||||
const INTERVAL = 1000;
|
||||
|
||||
@@ -24,37 +30,50 @@ export class HuiClockCardDigital extends LitElement {
|
||||
|
||||
@state() private _timeAmPm?: string;
|
||||
|
||||
@state() private _date?: string;
|
||||
|
||||
private _tickInterval?: undefined | number;
|
||||
|
||||
private _lastDateMinute?: string;
|
||||
|
||||
private _timeZone?: string;
|
||||
|
||||
private _language?: string;
|
||||
|
||||
private _initDate() {
|
||||
if (!this.config || !this.hass) {
|
||||
this._date = undefined;
|
||||
this._lastDateMinute = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let locale = this.hass?.locale;
|
||||
|
||||
if (this.config?.time_format) {
|
||||
locale = { ...locale, time_format: this.config.time_format };
|
||||
}
|
||||
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
|
||||
|
||||
const h12 = useAmPm(locale);
|
||||
this._language = this.hass.locale.language;
|
||||
this._timeZone = timeZone;
|
||||
|
||||
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
|
||||
hour: h12 ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: h12 ? "h12" : "h23",
|
||||
timeZone:
|
||||
this.config?.time_zone ||
|
||||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
|
||||
timeZone,
|
||||
});
|
||||
|
||||
this._lastDateMinute = undefined;
|
||||
|
||||
this._tick();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("hass")) {
|
||||
if (changedProps.has("config") || changedProps.has("hass")) {
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass || oldHass.locale !== this.hass?.locale) {
|
||||
if (
|
||||
changedProps.has("config") ||
|
||||
!oldHass ||
|
||||
oldHass.locale !== this.hass?.locale
|
||||
) {
|
||||
this._initDate();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +90,7 @@ export class HuiClockCardDigital extends LitElement {
|
||||
}
|
||||
|
||||
private _startTick() {
|
||||
this._stopTick();
|
||||
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
|
||||
this._tick();
|
||||
}
|
||||
@@ -85,7 +105,8 @@ export class HuiClockCardDigital extends LitElement {
|
||||
private _tick() {
|
||||
if (!this._dateTimeFormat) return;
|
||||
|
||||
const parts = this._dateTimeFormat.formatToParts();
|
||||
const date = new Date();
|
||||
const parts = this._dateTimeFormat.formatToParts(date);
|
||||
|
||||
this._timeHour = parts.find((part) => part.type === "hour")?.value;
|
||||
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
|
||||
@@ -93,6 +114,33 @@ export class HuiClockCardDigital extends LitElement {
|
||||
? parts.find((part) => part.type === "second")?.value
|
||||
: undefined;
|
||||
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
|
||||
|
||||
this._updateDate(date);
|
||||
}
|
||||
|
||||
private _updateDate(date: Date) {
|
||||
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
|
||||
this._date = undefined;
|
||||
this._lastDateMinute = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this._timeMinute !== undefined &&
|
||||
this._timeMinute === this._lastDateMinute &&
|
||||
this._date !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateConfig = getClockCardDateConfig(this.config);
|
||||
this._date = formatClockCardDate(
|
||||
date,
|
||||
dateConfig,
|
||||
this._language,
|
||||
this._timeZone
|
||||
);
|
||||
this._lastDateMinute = this._timeMinute;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -101,28 +149,65 @@ export class HuiClockCardDigital extends LitElement {
|
||||
const sizeClass = this.config.clock_size
|
||||
? `size-${this.config.clock_size}`
|
||||
: "";
|
||||
const showDate = hasClockCardDate(this.config);
|
||||
|
||||
return html`
|
||||
<div class="time-parts ${sizeClass}">
|
||||
<div class="time-part hour">${this._timeHour}</div>
|
||||
<div class="time-part minute">${this._timeMinute}</div>
|
||||
${
|
||||
this._timeSecond !== undefined
|
||||
? html`<div class="time-part second">${this._timeSecond}</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._timeAmPm !== undefined
|
||||
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
|
||||
: nothing
|
||||
}
|
||||
<div class="clock-container">
|
||||
<div class="time-parts ${sizeClass}">
|
||||
<div class="time-part hour">${this._timeHour}</div>
|
||||
<div class="time-part minute">${this._timeMinute}</div>
|
||||
${
|
||||
this._timeSecond !== undefined
|
||||
? html`<div class="time-part second">${this._timeSecond}</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._timeAmPm !== undefined
|
||||
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
showDate
|
||||
? html`<div class="date-container">
|
||||
<div class="date ${sizeClass}">
|
||||
${this._date
|
||||
?.split("\n")
|
||||
.map(
|
||||
(line) => html`
|
||||
<ha-marquee-text
|
||||
class="date-line"
|
||||
speed="10"
|
||||
pause-duration="1500"
|
||||
pause-on-hover
|
||||
>
|
||||
${line}
|
||||
</ha-marquee-text>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-container {
|
||||
width: 100%;
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.time-parts {
|
||||
@@ -192,6 +277,29 @@ export class HuiClockCardDigital extends LitElement {
|
||||
content: ":";
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: 1.1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-line {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date.size-medium {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
|
||||
.date.size-large {
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -445,12 +445,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
|
||||
time_format?: TimeFormat;
|
||||
time_zone?: string;
|
||||
no_background?: boolean;
|
||||
date_format?: ClockCardDatePart[];
|
||||
// Analog clock options
|
||||
border?: boolean;
|
||||
ticks?: "none" | "quarter" | "hour" | "minute";
|
||||
face_style?: "markers" | "numbers_upright" | "roman";
|
||||
}
|
||||
|
||||
export type ClockCardDatePart =
|
||||
| "weekday-short"
|
||||
| "weekday-long"
|
||||
| "day-numeric"
|
||||
| "day-2-digit"
|
||||
| "month-short"
|
||||
| "month-long"
|
||||
| "month-numeric"
|
||||
| "month-2-digit"
|
||||
| "year-2-digit"
|
||||
| "year-numeric"
|
||||
| "separator-dash"
|
||||
| "separator-slash"
|
||||
| "separator-dot"
|
||||
| "separator-new-line";
|
||||
|
||||
export interface MediaControlCardConfig extends LovelaceCardConfig {
|
||||
entity: string;
|
||||
name?: string | EntityNameItem | EntityNameItem[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@ import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
array,
|
||||
assert,
|
||||
assign,
|
||||
boolean,
|
||||
defaulted,
|
||||
enums,
|
||||
literal,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
union,
|
||||
} from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
@@ -19,58 +18,47 @@ import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { ClockCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
import { TimeFormat } from "../../../../data/translation";
|
||||
import { getTimezoneOptions } from "../../../../components/ha-timezone-picker";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
getClockCardDateConfig,
|
||||
} from "../../cards/clock/clock-date-format";
|
||||
|
||||
const cardConfigStruct = assign(
|
||||
baseLovelaceCardConfig,
|
||||
object({
|
||||
title: optional(string()),
|
||||
clock_style: optional(union([literal("digital"), literal("analog")])),
|
||||
clock_size: optional(
|
||||
union([literal("small"), literal("medium"), literal("large")])
|
||||
),
|
||||
clock_style: optional(enums(["digital", "analog"])),
|
||||
clock_size: optional(enums(["small", "medium", "large"])),
|
||||
time_format: optional(enums(Object.values(TimeFormat))),
|
||||
time_zone: optional(enums(getTimezoneOptions().map((option) => option.id))),
|
||||
show_seconds: optional(boolean()),
|
||||
no_background: optional(boolean()),
|
||||
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
|
||||
// Analog clock options
|
||||
border: optional(defaulted(boolean(), false)),
|
||||
ticks: optional(
|
||||
defaulted(
|
||||
union([
|
||||
literal("none"),
|
||||
literal("quarter"),
|
||||
literal("hour"),
|
||||
literal("minute"),
|
||||
]),
|
||||
literal("hour")
|
||||
)
|
||||
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
|
||||
),
|
||||
seconds_motion: optional(
|
||||
defaulted(
|
||||
union([literal("continuous"), literal("tick")]),
|
||||
literal("continuous")
|
||||
)
|
||||
defaulted(enums(["continuous", "tick"]), "continuous")
|
||||
),
|
||||
face_style: optional(
|
||||
defaulted(
|
||||
union([
|
||||
literal("markers"),
|
||||
literal("numbers_upright"),
|
||||
literal("roman"),
|
||||
]),
|
||||
literal("markers")
|
||||
)
|
||||
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
|
||||
time_format?: ClockCardConfig["time_format"] | "auto";
|
||||
};
|
||||
|
||||
@customElement("hui-clock-card-editor")
|
||||
export class HuiClockCardEditor
|
||||
extends LitElement
|
||||
@@ -93,7 +81,7 @@ export class HuiClockCardEditor
|
||||
name: "clock_style",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
mode: "box",
|
||||
options: ["digital", "analog"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
@@ -119,6 +107,13 @@ export class HuiClockCardEditor
|
||||
},
|
||||
{ name: "show_seconds", selector: { boolean: {} } },
|
||||
{ name: "no_background", selector: { boolean: {} } },
|
||||
{
|
||||
name: "date_format",
|
||||
required: false,
|
||||
selector: {
|
||||
ui_clock_date_format: {},
|
||||
},
|
||||
},
|
||||
...(clockStyle === "digital"
|
||||
? ([
|
||||
{
|
||||
@@ -241,18 +236,28 @@ export class HuiClockCardEditor
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
private _data = memoizeOne((config) => ({
|
||||
clock_style: "digital",
|
||||
clock_size: "small",
|
||||
time_format: "auto",
|
||||
show_seconds: false,
|
||||
no_background: false,
|
||||
// Analog clock options
|
||||
border: false,
|
||||
ticks: "hour",
|
||||
face_style: "markers",
|
||||
...config,
|
||||
}));
|
||||
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
|
||||
const dateConfig = getClockCardDateConfig(config);
|
||||
|
||||
const data: ClockCardFormData = {
|
||||
...config,
|
||||
clock_style: config.clock_style ?? "digital",
|
||||
clock_size: config.clock_size ?? "small",
|
||||
time_format: config.time_format ?? "auto",
|
||||
show_seconds: config.show_seconds ?? false,
|
||||
no_background: config.no_background ?? false,
|
||||
// Analog clock options
|
||||
border: config.border ?? false,
|
||||
ticks: config.ticks ?? "hour",
|
||||
face_style: config.face_style ?? "markers",
|
||||
};
|
||||
|
||||
if (config.date_format === undefined) {
|
||||
data.date_format = dateConfig.parts;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
public setConfig(config: ClockCardConfig): void {
|
||||
assert(config, cardConfigStruct);
|
||||
@@ -281,35 +286,40 @@ export class HuiClockCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
if (ev.detail.value.time_format === "auto") {
|
||||
delete ev.detail.value.time_format;
|
||||
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
|
||||
const config = ev.detail.value;
|
||||
|
||||
if (!config.date_format || config.date_format.length === 0) {
|
||||
delete config.date_format;
|
||||
}
|
||||
|
||||
if (ev.detail.value.clock_style === "analog") {
|
||||
ev.detail.value.border = ev.detail.value.border ?? false;
|
||||
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
|
||||
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
|
||||
if (ev.detail.value.show_seconds) {
|
||||
ev.detail.value.seconds_motion =
|
||||
ev.detail.value.seconds_motion ?? "continuous";
|
||||
if (config.time_format === "auto") {
|
||||
delete config.time_format;
|
||||
}
|
||||
|
||||
if (config.clock_style === "analog") {
|
||||
config.border = config.border ?? false;
|
||||
config.ticks = config.ticks ?? "hour";
|
||||
config.face_style = config.face_style ?? "markers";
|
||||
if (config.show_seconds) {
|
||||
config.seconds_motion = config.seconds_motion ?? "continuous";
|
||||
} else {
|
||||
delete ev.detail.value.seconds_motion;
|
||||
delete config.seconds_motion;
|
||||
}
|
||||
} else {
|
||||
delete ev.detail.value.border;
|
||||
delete ev.detail.value.ticks;
|
||||
delete ev.detail.value.face_style;
|
||||
delete ev.detail.value.seconds_motion;
|
||||
delete config.border;
|
||||
delete config.ticks;
|
||||
delete config.face_style;
|
||||
delete config.seconds_motion;
|
||||
}
|
||||
|
||||
if (ev.detail.value.ticks !== "none") {
|
||||
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
|
||||
if (config.ticks !== "none") {
|
||||
config.face_style = config.face_style ?? "markers";
|
||||
} else {
|
||||
delete ev.detail.value.face_style;
|
||||
delete config.face_style;
|
||||
}
|
||||
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
@@ -344,6 +354,10 @@ export class HuiClockCardEditor
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.no_background`
|
||||
);
|
||||
case "date_format":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.label`
|
||||
);
|
||||
case "border":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.label`
|
||||
@@ -369,6 +383,10 @@ export class HuiClockCardEditor
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "date_format":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.description`
|
||||
);
|
||||
case "border":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -260,9 +261,10 @@ export class HuiHistoryGraphCardEditor
|
||||
const domain = computeDomain(entityId);
|
||||
const isNumberDomain =
|
||||
domain === "counter" || domain === "number" || domain === "input_number";
|
||||
const stateObj = this.hass!.states[entityId];
|
||||
const stateObj = this.hass!.states[entityId] as HassEntity | undefined;
|
||||
const attributes = stateObj?.attributes;
|
||||
return !isNumericFromAttributes(attributes) && !isNumberDomain;
|
||||
const isNumeric = attributes ? isNumericFromAttributes(attributes) : false;
|
||||
return !isNumeric && !isNumberDomain;
|
||||
};
|
||||
|
||||
// remove "color" option when needed
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user