Compare commits

...

69 Commits

Author SHA1 Message Date
Wendelin
89fff3bc69 Refactor target extraction in HaAutomationActionRow for improved legacy support 2026-04-20 17:01:57 +02:00
Paul Bottein
93110b1d70 Fix section config not propagating to layout element (#51640) 2026-04-20 15:29:16 +02:00
Wendelin
541c112159 Automation add TCA: Auto-expand single floor values (#51639)
Auto-expand floor section if only one floor is present
2026-04-20 14:06:58 +02:00
Aidan Timson
84382fdf0d Support scrolling on heading cards (#51567)
* Clean

* Scrollable option

* Refactor

* Remove wrap

* Restore

* Show grab cursor when dragging

* Overflow handling

* Remove extra space on start and end item

* Shrink title

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Increase min width of title, set var

* More specific

* Use 120px min on small layouts

* Try to fix sizing

* No unnessasary vars

* Format

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-20 14:00:12 +02:00
Marcin Bauer
591057b80d Tooltip styling and sidebar tooltips (#30386)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-20 11:37:48 +00:00
Wendelin
d220725e5b ha-switch webawesome (#51507) 2026-04-20 13:19:53 +02:00
renovate[bot]
fdb4de9aa8 Update dependency @rsdoctor/rspack-plugin to v1.5.9 (#51636)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 13:09:24 +03:00
Aidan Timson
c3b768c111 Allow for decimals in cover and valve favorites (#51633)
* Allow for decimals in cover and valve favorites

* Merge normalise functions
2026-04-20 12:43:38 +03:00
Wendelin
7d9874adfa Remove allow-mode-change in quick search (#51634)
Remove allow-mode-change attribute from ha-adaptive-dialog in QuickBar component
2026-04-20 12:33:29 +03:00
renovate[bot]
64ad41a533 Update Yarn to v4.14.1 (#51631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 09:16:38 +03:00
renovate[bot]
520739dd0e Update formatjs monorepo (#51601)
* Update formatjs monorepo

* Fix types

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-20 06:07:59 +00:00
Aidan Timson
30f70e179a Fix inconsistency with template binary sensors (#51486)
Fix inconsistency with template binary sensors with template options flow
2026-04-20 08:35:49 +03:00
Paulus Schoutsen
e66564ff65 Add apps group to navigation picker with all web UI addons (#51572)
* Add apps navigation group with ingress add-on panels

Add an "Apps" section to the navigation picker that shows all add-ons
with ingress support. Uses the /ingress/panels supervisor endpoint via
a cached collection to fetch add-on titles and icons in a single call.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Fix no-shadow lint error for panels variable

Rename subscribe callback parameter from `panels` to `data` to avoid
shadowing the outer `panels` variable in _loadNavigationItems.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Use subscribeOne helper for ingress panels collection

Replace hand-rolled Promise/subscribe/unsub pattern with the existing
subscribeOne utility for cleaner one-shot collection consumption.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Add explicit type parameter to subscribeOne call

TypeScript cannot infer the generic type through the collection
subscribe chain, resulting in unknown type for panel entries.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Add subscribeOneCollection helper for collection one-shot reads

Add a new subscribeOneCollection utility that takes a collection
directly instead of requiring the (conn, onChange) function pattern.
Use it in the navigation picker for cleaner ingress panel fetching.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Use Collection type instead of custom Subscribable interface

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Add ingress panel support to subscribeNavigationPathInfo

* Use app panel variable

* Add tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-20 08:28:01 +03:00
Paulus Schoutsen
70ac14ed52 Allow disabling the maintenance summary from the home editor (#51622)
https://claude.ai/code/session_01FkRan4yxJzdjJJmjSRtoY1

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 08:27:14 +03:00
renovate[bot]
e0d881ff53 Update dependency marked to v18.0.1 (#51630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 06:50:50 +02:00
Logan Rosen
61c0b7394e Remove core-only development checklist from PR template (#51624)
The PR template includes a checklist item linking to the development
checklist page, but that page only covers core/Python-specific items
(pypi, requirements_all.txt, CODEOWNERS, .strict-typing, Ruff). None
of these apply to the frontend repository.

No frontend-specific development checklist exists, so remove the item
entirely rather than link to irrelevant documentation. The "perfect PR
recommendations" checklist item already covers general PR best practices.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 06:50:33 +02:00
ildar170975
34b2509a76 Gauge card: fix a height in Horizontal stack (#51626)
remove styles for ":host"
2026-04-20 06:46:28 +02:00
renovate[bot]
7d03ef6dfc Update dependency typescript to v6.0.3 (#51628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 06:41:27 +02:00
renovate[bot]
96b59c6171 Update Yarn to v4.14.0 (#51621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 22:18:04 +02:00
renovate[bot]
7691d2ca4a Update dependency @codemirror/lang-jinja to v6.0.1 (#51618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 22:17:47 +02:00
Simon Lamon
da1c2bdee4 Adjust gauge again (#51613) 2026-04-19 08:55:54 +03:00
renovate[bot]
509443fbb2 Update dependency @bundle-stats/plugin-webpack-filter to v4.22.1 (#51610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-18 09:02:13 +02:00
renovate[bot]
07992286b5 Update dependency prettier to v3.8.3 (#51611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-18 09:01:52 +02:00
renovate[bot]
cf7274b0ba Update dependency @rsdoctor/rspack-plugin to v1.5.8 (#51605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 18:53:54 +02:00
Paulus Schoutsen
501c72d203 Add config sub-routes to navigation picker (#51597)
Add Automations, Scenes, Scripts, Developer Tools, Integrations,
Devices, and Entities to the "Other routes" section of the navigation
picker. Also resolve these paths with proper labels and icons in
computeNavigationPathInfo so they display correctly everywhere
(shortcut cards, edit overview, etc.).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:20:03 +03:00
karwosts
a0ad488579 Fix gauge missing label on load (#51584) 2026-04-17 08:18:16 +03:00
renovate[bot]
ead2d1296f Update dependency hls.js to v1.6.16 (#51599)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 07:09:15 +02:00
renovate[bot]
5ba5408e78 Update dependency typescript-eslint to v8.58.2 (#51600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 07:08:59 +02:00
Paulus Schoutsen
eecca1fa55 Add ESPHome logo (#51598) 2026-04-16 12:38:53 -04:00
Jeremy Cook
f2ba0fca73 Add per-section theme support (#29745)
* Add per-section theme support

* Fix linting errors: rename property parameter and use dot notation

* Fix TypeScript error: cast to any for __themes property

* Refactor theme application logic for race condition on first load, missing reconnect handling, and the fragile _applyTheme internals https://github.com/home-assistant/frontend/pull/29745

* correct formatting with prettier --write

* Fix theme application logic on reconnect by checking for theme configuration

* Pass section theme to background component for theme variable access

Section backgrounds now receive the section's theme and hass properties,
allowing them to apply the theme via applyThemesOnElement(). This enables
background components to access CSS variables from the section's theme,
particularly --ha-section-background-color when using the 'Default' color option.

Previously, the background component was rendered as a sibling to the section
element and couldn't access theme variables from the section's applied theme.
Now the theme is explicitly passed from hui-sections-view and applied to the
background component itself, making theme cascading work correctly.

* Reorder section settings: theme before background

* Add helper text support to theme selector

Theme selectors can now display helper text below the dropdown. Added helper property to ha-selector-theme and ha-theme-picker components, which is passed through to ha-select. Updated section theme label and added helper text to explain its purpose.

* Address PR review feedback: move theme to end and simplify label

- Move theme selector to end of form (after background section)
- Change label from 'Section theme' to 'Theme' as context is already clear

* Handle theme removal for background

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-16 15:20:08 +00:00
Paulus Schoutsen
fc448ab3a7 Add serial selector to initial form data (#51595)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:46:11 +01:00
Paul Bottein
9269c1ff0a Redesign lawn mower more info dialog (#51596) 2026-04-16 16:45:21 +02:00
Petar Petrov
b7dcbd559e Add hourly forecast card feature for weather entities (#51594) 2026-04-16 14:42:10 +01:00
Paul Bottein
80e0c098f8 Redesign vacuum more info dialog (#51380)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-16 14:11:07 +02:00
Aidan Timson
364c793ee6 Support suggested name and icon for dashboards, add to map (#51592) 2026-04-16 14:03:44 +02:00
Aidan Timson
99f36e1aad Refactor weather forecast card to scroll (#51580)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-16 11:42:06 +00:00
Aidan Timson
25dcaa4eb8 Device and browser environment for debug tools (#51568)
* Add viewpoer environment card to debug tools

* Move

* Cleanup

* Remove

* Remove scroll listener

* Update translation

* Output as yaml

* Apply suggestion

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

* Finish

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-16 13:32:29 +03:00
Wendelin
d92f7e14b4 ha-checkbox with webawesome (#51581)
* ha-checkbox with webawesome

* Fix checkboxes

* remove mwc-checkbox

* Copilot review

* Fix data table range checkbox select
2026-04-16 13:12:04 +03:00
renovate[bot]
2c1bf3369d Update Node.js to v24.15.0 (#51590)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 11:45:13 +03:00
Paulus Schoutsen
81d57cf43c Add SerialSelector (#51573)
* Add SerialSelector

* Address comments
2026-04-16 10:45:45 +03:00
Paulus Schoutsen
09053533ff Add custom pages summaries (#51506)
* Add custom pages to home page summaries

* Address comments

* Extract helper

* Update UI editor and use shortcut card

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-16 10:02:11 +03:00
Aidan Timson
7df61f239f Offset toast position, apply for automation/script editor (#51575)
* Alllow setting of toast position

* Switch to use offset

* Create wrapper for toast to offset with automation editor

* Use wrapper

* Add for clipboard pastes

* Make automation toasts dismissable

* Apply for script editor

* Rename
2026-04-16 09:57:06 +03:00
Paulus Schoutsen
f89eace462 Resolve add-on name and icon for shortcut card /app/ navigation (#51587)
* Resolve add-on name and icon for shortcut card /app/ navigation paths

When a shortcut card or badge navigates to /app/{slug}, fetch the add-on's
panel info (title and icon) from the supervisor ingress panels endpoint and
display it instead of the generic "app" panel fallback.

Adds a cached collection for ingress panel data (title and icon per add-on)
using getCollection, so the data is fetched once per connection.

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

* Simplify

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 09:41:36 +03:00
Wendelin
52956eefc6 ha-select allow number values (#51564) 2026-04-16 08:29:03 +02:00
Tom Carpenter
1fbbeba083 Correct statistic graph chart types to sentence case (#51579)
Correct statistic chart types to sentence case
2026-04-16 08:48:22 +03:00
Jan Čermák
4e0d2e290a Fix rendering of select with multiple options in SupervisorAppConfig (#51585)
If the app config contains a schema field like this one:

```
privileges:
  - "list(ALTER|CREATE|...|UPDATE)?"
```

it was rendered incorrectly as a drop-down where only one item can be
selected - but this is wrong because of the preceding `-` denoting it
should be a list containing the listed values. Supervisor translated
this to an entry of type `select` with `multiple: true`. The `multiple`
flag wasn't passed along, with the flag set the field renders as
expected.

Fixes #51533
2026-04-16 08:47:48 +03:00
Paulus Schoutsen
641773d5c4 Add Music Assistant icon (#51586)
* Add Music Assistant fallback domain icon

Add mdiMusic as the fallback icon for the music_assistant domain
in FALLBACK_DOMAIN_ICONS, so the integration has a proper icon
when dynamic icons are unavailable.

https://claude.ai/code/session_01GfNQCZL3dF1GXLRfhNRt25

* Revert "Add Music Assistant fallback domain icon"

This reverts commit 130d6eddee.

* Add Music Assistant logo as injectable mdi icon

Add the Music Assistant logo SVG as a special named icon, following
the same pattern as the Home Assistant logo (mdi:home-assistant).
This allows referencing mdi:music-assistant anywhere in the frontend
(e.g., add-on sidebar icon) and it resolves to the Music Assistant
logo SVG path.

https://claude.ai/code/session_01GfNQCZL3dF1GXLRfhNRt25

* Simplify logic to add more in future

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 19:28:20 -04:00
karwosts
3b53867216 Fix gauge segmentLabels, cleanup sorting (#51583) 2026-04-15 16:57:17 +03:00
Paul Bottein
7ea936088c Add shortcut badge (#51569)
* Add shortcut badge

* Fix label

* Update src/translations/en.json

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update card description

* Feedbacks

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-04-15 16:54:19 +03:00
Aidan Timson
4281240383 Add grab cursor to more info weather forecast (#51582) 2026-04-15 14:58:19 +02:00
Timothy
6b6203986d Increase height of HAInputSearch (#51576) 2026-04-15 13:53:55 +01:00
renovate[bot]
6997ffa580 Update dependency globals to v17.5.0 (#51574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 12:47:24 +03:00
Raphael Hehl
2d2558db40 Fix history/sensor cards stuck loading after backend restart (#51531)
* Fix history/sensor cards stuck loading after backend restart

- Add { resubscribe: false } to history subscriptions to prevent
  corrupt HistoryStream state on auto-resubscription
- Add connection-status handlers to re-subscribe on reconnect
- Add sentinel pattern to prevent re-entrant async subscriptions
- Add shouldUpdate/updated retry when components become available
- Clear sensor device classes cache on WS error
- Clear _error on reconnect so cards can retry
- Add .catch() on unsubscribe to handle dead subscriptions

* Fix type annotation for callWS in getSensorNumericDeviceClasses

* Address review: type connection-status handlers, add reconnect to history panel

- Use HASSDomEvent<ConnectionStatus> instead of (ev as CustomEvent).detail
  for proper type safety on all connection-status handlers
- Add connection-status handler to ha-panel-history so it re-subscribes
  after backend restart (addresses concern about resubscribe: false)

* Address review: sentinel pattern, reconnect handling, stale data reset

- Add sentinel pattern to ha-more-info-history, ha-panel-history,
  hui-history-graph-card to prevent re-entrant subscription races
- Refactor hui-trend-graph-card-feature from SubscribeMixin to manual
  subscription management with connection-status reconnect support
- Reset stale history/statistics data on reconnect in
  hui-history-graph-card and hui-map-card before re-subscribing
- Wrap fetchStatistics and getSensorNumericDeviceClasses calls in
  ha-panel-history with try/catch to handle errors gracefully
- Chain .catch directly on subscribeHistoryStatesTimeWindow in
  hui-trend-graph-card-feature to avoid detached-promise race condition

* Centralize history stream reconnect handling in data layer

Move the reconnect logic from every consumer into `subscribeHistoryStream`
in data/history.ts. The helper listens to the connection's `ready` event
itself, and on reconnect creates a fresh `HistoryStream` and rebuilds
params (so `start_time` for the time-window variant is re-anchored to
"now"). `resubscribe: false` stays as an internal implementation detail.

Removes the duplicated `_handleConnectionStatus` boilerplate and
`connection-status` window listeners from all six history consumers.

* Render subscription errors and make _error reactive

`_error` was declared as a plain string field in hui-graph-header-footer
and ha-more-info-history (non-reactive) and typed as Error/string while
being assigned the WS error object. hui-trend-graph-card-feature had it
reactive but never rendered it.

Align all three with the hui-history-graph-card pattern: reactive
`{ code, message }` and a user-visible error branch in render(). Without
this, a failed subscription would leave the component stuck on a spinner
forever.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-15 11:34:13 +03:00
Paul Bottein
039fc45532 Add shortcut card (#51562)
* Add shortcut card

* Improve action support
2026-04-15 08:53:08 +03:00
renovate[bot]
209e6f8def Update dependency sinon to v21.1.2 (#51571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 20:44:08 +02:00
Yosi Levy
f6a19eb6c4 Fix entity ID orientation in device editor (#51560)
* Fix entity ID orientation

* Apply suggestions from code review

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Fix indentation in entity-registry-settings-editor.ts

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-14 17:49:39 +00:00
karwosts
ceb9967deb Make picture elements editor sortable (#51563)
* Make picture elements editor sortable

* Update src/panels/lovelace/editor/hui-picture-elements-card-row-editor.ts

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

* Update src/panels/lovelace/editor/hui-picture-elements-card-row-editor.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-14 15:00:07 +00:00
Tom Carpenter
b2015465fb Add stacked chart types to Statistics Graph Card (#51530)
* Add stacked mode to statistics-chart

Allows displaying the entries as stacked lines or stacked bars. This means we can create charts similar to the energy graph cards but with alternate entities.

* Move fillDataGapsAndRoundCaps to components/chart

Now used in statistics-chart too, so move to common chart location.
Re-export in energy-chart-options.ts to minimise changes throughout energy cards.

* Correct order of line/bar in statistics graph card editor

Line and Bar options were unintentionally reversed in the displayed list.

* Support unstacked bar charts in fillDataGapsAndRoundCaps
2026-04-14 17:45:47 +03:00
karwosts
8e4c99049f Adjust outlier detection algorithm when partial 5minute data exists (#51561) 2026-04-14 16:44:12 +03:00
Petar Petrov
5a5b8c0bbd Add controls option to media player playback card feature (#30338)
* Add controls option to media player playback card feature

Allow users to configure which playback controls are shown and in what
order. When controls are explicitly configured, each selected control
is rendered as its own button in the specified order. When no controls
are configured, the original default behavior is preserved.

Also adds a configuration editor for the feature and fixes inline
feature padding in the tile card container.

* Revert padding changes to ha-tile-container

* Use computeMediaControls for default playback buttons

Reuse the shared computeMediaControls function for the default (no
explicit controls) path instead of duplicating the logic. Apply the
narrow filter to both paths via a shared _filterNarrow method.
2026-04-14 16:29:57 +03:00
Petar Petrov
b60d189a69 Remove invalid dependabot cooldown option (#51558) 2026-04-14 10:28:35 +02:00
Aidan Timson
19ed00c677 Combine all entity modes card feature editors with shared base class (#51543)
* Combine all entity modes card feature editors with shared base class

* Remove hass from schema memo
2026-04-14 11:26:55 +03:00
Bram Kragten
b92775ea2d Improve code editors (#51555)
* Update YAML and Jinja code editor support, support Jinja in YAML

* add autocomplete for ha jinja functions

* Use snippets and better autocomplete for jinja

* Add autocomplete for devices

* Add area, floor, label autocomplete

* Add yaml scalar type highlighter

* Add autocomplete for `states` var

* Add autocomplete for attributes

* Make autocomplete work on id and name

* Add missing functions that can also be used as filter
2026-04-14 09:19:37 +03:00
renovate[bot]
b5bacf85dd Update dependency sinon to v21.1.1 (#51557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-14 08:42:37 +03:00
Yosi Levy
8f4fe9ba4e Fix date picker header (#51552) 2026-04-13 22:02:36 +02:00
Wendelin
9179218336 Improve view footer card visibility handling (#51549) 2026-04-13 17:12:57 +02:00
JamesFromIT
274ec50dbd Make dialog title a semantic heading element (#51521)
* Make dialog title a semantic heading element

* Move away from inheriting everything from modal title parent

* Update src/dialogs/generic/dialog-box.ts

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-13 12:38:31 +00:00
Wendelin
2629881a18 Hide footer if card is not visible (#51544)
* Fix footer visibility logic in render method

* use card-visibility-changed
2026-04-13 14:00:44 +02:00
dependabot[bot]
d7f143a65a Bump actions/github-script from 8.0.0 to 9.0.0 (#51539)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8.0.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](ed597411d8...3a2844b7e9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 13:17:21 +02:00
219 changed files with 11977 additions and 4049 deletions

View File

@@ -69,7 +69,6 @@
- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally.
- [ ] There is no commented out code in this PR.
- [ ] I have followed the [development checklist][dev-checklist]
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
@@ -105,6 +104,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr

View File

@@ -6,7 +6,6 @@ updates:
interval: weekly
time: "06:00"
cooldown:
default-days-before-reopen: 30
default-days: 7
open-pull-requests-limit: 10
labels:

View File

@@ -22,7 +22,7 @@ jobs:
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.issues.addLabels({
@@ -41,7 +41,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;

2
.nvmrc
View File

@@ -1 +1 @@
24.14.1
24.15.0

File diff suppressed because one or more lines are too long

View File

@@ -8,4 +8,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.14.1.cjs

View File

@@ -3,37 +3,73 @@ title: Switch / Toggle
---
<style>
ha-switch {
display: block;
.wrapper {
display: flex;
gap: 24px;
align-items: center;
}
</style>
# Switch `<ha-switch>`
A toggle switch can represent two states: on and off.
A toggle switch representing two states: on and off.
## Examples
## Implementation
Switch in on state
### Example usage
<div class="wrapper">
<ha-switch checked></ha-switch>
<ha-switch></ha-switch>
<ha-switch disabled></ha-switch>
<ha-switch disabled checked></ha-switch>
</div>
```html
<ha-switch checked></ha-switch>
Switch in off state
<ha-switch></ha-switch>
Disabled switch
<ha-switch disabled></ha-switch>
## CSS variables
<ha-switch disabled checked></ha-switch>
```
For the switch / toggle there are always two variables, one for the on / checked state and one for the off / unchecked state.
### API
The track element (background rounded rectangle that the round circular handle travels on) is set to being half transparent, so the final color will also be impacted by the color behind the track.
This component is based on the webawesome switch component.
Check the [webawesome documentation](https://webawesome.com/docs/components/switch/) for more details.
`switch-checked-color` / `switch-unchecked-color`
Set both the color of the round handle and the track behind it. If you want to control them separately, use the variables below instead.
**Properties/Attributes**
`switch-checked-button-color` / `switch-unchecked-button-color`
Color of the round handle
| Name | Type | Default | Description |
| -------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| checked | Boolean | false | The checked state of the switch. |
| disabled | Boolean | false | Disables the switch and prevents user interaction. |
| required | Boolean | false | Makes the switch a required field. |
| haptic | Boolean | false | Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when save is required). |
`switch-checked-track-color` / `switch-unchecked-track-color`
Color of the track behind the round handle
**CSS Custom Properties**
- `--ha-switch-size` - The size of the switch track height. Defaults to `24px`.
- `--ha-switch-thumb-size` - The size of the thumb. Defaults to `18px`.
- `--ha-switch-width` - The width of the switch track. Defaults to `48px`.
- `--ha-switch-thumb-box-shadow` - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
- `--ha-switch-background-color` - Background color of the unchecked track.
- `--ha-switch-thumb-background-color` - Background color of the unchecked thumb.
- `--ha-switch-background-color-hover` - Background color of the unchecked track on hover.
- `--ha-switch-thumb-background-color-hover` - Background color of the unchecked thumb on hover.
- `--ha-switch-border-color` - Border color of the unchecked track.
- `--ha-switch-thumb-border-color` - Border color of the unchecked thumb.
- `--ha-switch-thumb-border-color-hover` - Border color of the unchecked thumb on hover.
- `--ha-switch-checked-background-color` - Background color of the checked track.
- `--ha-switch-checked-thumb-background-color` - Background color of the checked thumb.
- `--ha-switch-checked-background-color-hover` - Background color of the checked track on hover.
- `--ha-switch-checked-thumb-background-color-hover` - Background color of the checked thumb on hover.
- `--ha-switch-checked-border-color` - Border color of the checked track.
- `--ha-switch-checked-thumb-border-color` - Border color of the checked thumb.
- `--ha-switch-checked-border-color-hover` - Border color of the checked track on hover.
- `--ha-switch-checked-thumb-border-color-hover` - Border color of the checked thumb on hover.
- `--ha-switch-disabled-opacity` - Opacity of the switch when disabled. Defaults to `0.2`.
- `--ha-switch-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
- `--ha-switch-required-marker-offset` - Offset of the required marker. Defaults to `0.1rem`.

View File

@@ -1 +1,95 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../../src/types";
@customElement("demo-components-ha-switch")
export class DemoHaSwitch extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant;
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-switch ${mode}">
<div class="card-content">
<div class="row">
<span>Unchecked</span>
<ha-switch></ha-switch>
</div>
<div class="row">
<span>Checked</span>
<ha-switch checked></ha-switch>
</div>
<div class="row">
<span>Disabled</span>
<ha-switch disabled></ha-switch>
</div>
<div class="row">
<span>Disabled checked</span>
<ha-switch disabled checked></ha-switch>
</div>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
margin: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-switch": DemoHaSwitch;
}
}

View File

@@ -149,6 +149,38 @@ const CONFIGS = [
max: 1.9
unit: GBP/h`,
},
{
heading: "A lot of segments",
config: `
- type: gauge
needle: true
name: Percent gauge
entity: sensor.brightness_high
unit: "%"
min: 0
max: 100
segments:
- from: 0
color: "#db4437"
- from: 10
color: "#cc4d39"
- from: 20
color: "#bd563a"
- from: 30
color: "#ad603c"
- from: 40
color: "#9e693d"
- from: 50
color: "#8f723f"
- from: 60
color: "#807b41"
- from: 70
color: "#718442"
- from: 80
color: "#618e44"
- from: 90
color: "#43a047"`,
},
];
@customElement("demo-lovelace-gauge-card")

View File

@@ -0,0 +1,3 @@
---
title: Lawn mower
---

View File

@@ -0,0 +1,98 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/dialogs/more-info/more-info-content";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-more-infos";
import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
const ALL_FEATURES =
LawnMowerEntityFeature.START_MOWING +
LawnMowerEntityFeature.PAUSE +
LawnMowerEntityFeature.DOCK;
const ENTITIES = [
{
entity_id: "lawn_mower.full_featured",
state: "docked",
attributes: {
friendly_name: "Full featured mower",
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.mowing",
state: "mowing",
attributes: {
friendly_name: "Mowing",
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.returning",
state: "returning",
attributes: {
friendly_name: "Returning",
supported_features:
LawnMowerEntityFeature.START_MOWING +
LawnMowerEntityFeature.PAUSE +
LawnMowerEntityFeature.DOCK,
},
},
{
entity_id: "lawn_mower.paused",
state: "paused",
attributes: {
friendly_name: "Paused",
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.error",
state: "error",
attributes: {
friendly_name: "Error",
supported_features:
LawnMowerEntityFeature.START_MOWING + LawnMowerEntityFeature.DOCK,
},
},
{
entity_id: "lawn_mower.basic",
state: "docked",
attributes: {
friendly_name: "Basic mower",
supported_features: LawnMowerEntityFeature.START_MOWING,
},
},
];
@customElement("demo-more-info-lawn-mower")
class DemoMoreInfoLawnMower extends LitElement {
@property({ attribute: false }) public hass!: MockHomeAssistant;
@query("demo-more-infos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`
<demo-more-infos
.hass=${this.hass}
.entities=${ENTITIES.map((ent) => ent.entity_id)}
></demo-more-infos>
`;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-more-info-lawn-mower": DemoMoreInfoLawnMower;
}
}

View File

@@ -8,18 +8,101 @@ import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-more-infos";
import { VacuumEntityFeature } from "../../../../src/data/vacuum";
const ALL_FEATURES =
VacuumEntityFeature.STATE +
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
VacuumEntityFeature.CLEAN_AREA;
const ENTITIES = [
{
entity_id: "vacuum.first_floor_vacuum",
entity_id: "vacuum.full_featured",
state: "docked",
attributes: {
friendly_name: "First floor vacuum",
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
},
},
{
entity_id: "vacuum.cleaning_vacuum",
state: "cleaning",
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
},
},
{
entity_id: "vacuum.returning_vacuum",
state: "returning",
attributes: {
friendly_name: "Returning vacuum",
supported_features:
VacuumEntityFeature.STATE +
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
status: "Returning to dock",
},
},
{
entity_id: "vacuum.error_vacuum",
state: "error",
attributes: {
friendly_name: "Error vacuum",
supported_features:
VacuumEntityFeature.STATE +
VacuumEntityFeature.START +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.LOCATE,
status: "Stuck on obstacle",
},
},
{
entity_id: "vacuum.basic_vacuum",
state: "docked",
attributes: {
friendly_name: "Basic vacuum",
supported_features:
VacuumEntityFeature.START +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME,
},
},
{
entity_id: "vacuum.paused_vacuum",
state: "paused",
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
},
},
];
@customElement("demo-more-info-vacuum")

View File

@@ -30,22 +30,23 @@
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.3.1",
"@formatjs/intl-displaynames": "7.3.1",
"@formatjs/intl-durationformat": "0.10.3",
"@formatjs/intl-getcanonicallocales": "3.2.2",
"@formatjs/intl-listformat": "8.3.1",
"@formatjs/intl-locale": "5.3.1",
"@formatjs/intl-numberformat": "9.3.1",
"@formatjs/intl-pluralrules": "6.3.1",
"@formatjs/intl-relativetimeformat": "12.3.1",
"@formatjs/intl-datetimeformat": "7.3.2",
"@formatjs/intl-displaynames": "7.3.2",
"@formatjs/intl-durationformat": "0.10.4",
"@formatjs/intl-getcanonicallocales": "3.2.3",
"@formatjs/intl-listformat": "8.3.2",
"@formatjs/intl-locale": "5.3.2",
"@formatjs/intl-numberformat": "9.3.2",
"@formatjs/intl-pluralrules": "6.3.2",
"@formatjs/intl-relativetimeformat": "12.3.2",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -61,12 +62,10 @@
"@lit/reactive-element": "2.1.2",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-checkbox": "0.27.0",
"@material/mwc-drawer": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-radio": "0.27.0",
"@material/mwc-switch": "0.27.0",
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
@@ -97,10 +96,10 @@
"fuse.js": "7.3.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.15",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.0",
"intl-messageformat": "11.2.1",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -108,7 +107,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "18.0.0",
"marked": "18.0.1",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -135,7 +134,7 @@
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.0",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
@@ -143,7 +142,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.7",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "1.7.11",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -181,7 +180,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.4.0",
"globals": "17.5.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -196,15 +195,15 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.2",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.1.0",
"sinon": "21.1.2",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.2",
"typescript-eslint": "8.58.1",
"typescript": "6.0.3",
"typescript-eslint": "8.58.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.4",
"webpack-stats-plugin": "1.1.3",
@@ -217,13 +216,13 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.4.0",
"globals": "17.5.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.13.0",
"packageManager": "yarn@4.14.1",
"volta": {
"node": "24.14.1"
"node": "24.15.0"
}
}

View File

@@ -9,7 +9,6 @@ import "../components/ha-alert";
import "../components/ha-button";
import "../components/ha-checkbox";
import { computeInitialHaFormData } from "../components/ha-form/compute-initial-ha-form-data";
import "../components/ha-formfield";
import type { AuthProvider } from "../data/auth";
import {
autocompleteLoginFields,
@@ -97,11 +96,6 @@ export class HaAuthFlow extends LitElement {
protected render() {
return html`
<style>
ha-auth-flow .store-token {
margin-left: -16px;
margin-inline-start: -16px;
margin-inline-end: initial;
}
a.forgot-password {
color: var(--primary-color);
text-decoration: none;
@@ -121,6 +115,9 @@ export class HaAuthFlow extends LitElement {
display: block;
margin-top: 16px;
}
.action {
margin-top: var(--ha-space-5);
}
.action ha-button {
width: 100%;
}
@@ -249,17 +246,12 @@ export class HaAuthFlow extends LitElement {
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<ha-formfield
class="store-token"
.label=${this.localize(
"ui.panel.page-authorize.store_token"
)}
<ha-checkbox
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
<ha-checkbox
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
></ha-checkbox>
</ha-formfield>
${this.localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""}
<a

View File

@@ -1,4 +1,3 @@
import type { DurationInput } from "@formatjs/intl-durationformat/src/types";
import memoizeOne from "memoize-one";
import type { HaDurationData } from "../../components/ha-duration-input";
import type { FrontendLocaleData } from "../../data/translation";
@@ -114,7 +113,7 @@ export const formatDuration = (
case "d": {
const days = Math.floor(value);
const hours = Math.floor((value - days) * 24);
const input: DurationInput = {
const input = {
days,
hours,
};
@@ -123,7 +122,7 @@ export const formatDuration = (
case "h": {
const hours = Math.floor(value);
const minutes = Math.floor((value - hours) * 60);
const input: DurationInput = {
const input = {
hours,
minutes,
};
@@ -132,7 +131,7 @@ export const formatDuration = (
case "min": {
const minutes = Math.floor(value);
const seconds = Math.floor((value - minutes) * 60);
const input: DurationInput = {
const input = {
minutes,
seconds,
};

View File

@@ -37,7 +37,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
case "person":
return compareState !== "not_home";
case "lawn_mower":
return ["mowing", "error"].includes(compareState);
return !["docked", "paused"].includes(compareState);
case "lock":
return compareState !== "locked";
case "media_player":

View File

@@ -1,4 +1,8 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type {
Collection,
Connection,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
export const subscribeOne = async <T>(
conn: Connection,
@@ -13,3 +17,11 @@ export const subscribeOne = async <T>(
resolve(items);
});
});
export const subscribeOneCollection = async <T>(collection: Collection<T>) =>
new Promise<T>((resolve) => {
const unsub = collection.subscribe((data) => {
unsub();
resolve(data);
});
});

View File

@@ -0,0 +1,103 @@
import type { BarSeriesOption } from "echarts/types/dist/shared";
export function fillDataGapsAndRoundCaps(
datasets: BarSeriesOption[],
stacked = true
) {
if (!stacked) {
// For non-stacked charts, we can simply apply an overall border to each stack
// to curve the top of the bar, and then override on any negative bars.
datasets.forEach((dataset) => {
// Add upper border radius to stack
dataset.itemStyle = {
...dataset.itemStyle,
borderRadius: [4, 4, 0, 0],
};
// And override any negative points to have bottom border curved
for (let pointIdx = 0; pointIdx < dataset.data!.length; pointIdx++) {
const dataPoint = dataset.data![pointIdx];
const item: any =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: { value: dataPoint };
if (item.value?.[1] < 0) {
dataset.data![pointIdx] = {
...item,
itemStyle: {
...item.itemStyle,
borderRadius: [0, 0, 4, 4],
},
};
}
}
});
return;
}
// For stacked charts, we need to carefully work through the data points in each
// stack to ensure only the lowermost negative and uppermost positive values have
// a curved border.
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat()
)
).sort((a, b) => a - b);
// make sure all datasets have the same buckets
// otherwise the chart will render incorrectly in some cases
buckets.forEach((bucket, index) => {
const capRounded = {};
const capRoundedNegative = {};
for (let i = datasets.length - 1; i >= 0; i--) {
const dataPoint = datasets[i].data![index];
const item: any =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: { value: dataPoint };
const x = item.value?.[0];
const stack = datasets[i].stack ?? "";
if (x === undefined) {
continue;
}
if (Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
borderWidth: 0,
},
});
} else if (item.value?.[1] === 0) {
// remove the border for zero values or it will be rendered
datasets[i].data![index] = {
...item,
itemStyle: {
...item.itemStyle,
borderWidth: 0,
},
};
} else if (!capRounded[stack] && item.value?.[1] > 0) {
datasets[i].data![index] = {
...item,
itemStyle: {
...item.itemStyle,
borderRadius: [4, 4, 0, 0],
},
};
capRounded[stack] = true;
} else if (!capRoundedNegative[stack] && item.value?.[1] < 0) {
datasets[i].data![index] = {
...item,
itemStyle: {
...item.itemStyle,
borderRadius: [0, 0, 4, 4],
},
};
capRoundedNegative[stack] = true;
}
}
});
}

View File

@@ -35,6 +35,7 @@ import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { fillDataGapsAndRoundCaps } from "./round-caps";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -67,7 +68,11 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false })
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
@property({ attribute: false }) public chartType:
| "line"
| "line-stack"
| "bar"
| "bar-stack" = "line";
@property({ attribute: false }) public minYAxis?: number;
@@ -326,7 +331,7 @@ export class StatisticsChart extends LitElement {
},
position: computeRTL(this.hass) ? "right" : "left",
scale:
this.chartType !== "bar" ||
this.chartType.startsWith("line") ||
this.logarithmicScale ||
minYAxis !== undefined ||
maxYAxis !== undefined,
@@ -386,6 +391,8 @@ export class StatisticsChart extends LitElement {
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
let colorIndex = 0;
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
const chartStacked = this.chartType.endsWith("stack");
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: typeof this._chartData = [];
const legendData: {
@@ -471,19 +478,17 @@ export class StatisticsChart extends LitElement {
}
statDataSets.forEach((d, i) => {
if (
this.chartType === "line" &&
chartType === "line" &&
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push(
this._transformDataValue([prevEndTime, ...prevValues[i]!])
);
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
@@ -503,7 +508,8 @@ export class StatisticsChart extends LitElement {
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
const hasMin =
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
const drawBands = [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
const drawBands =
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
const hasState = this.statTypes.includes("state");
@@ -535,8 +541,8 @@ export class StatisticsChart extends LitElement {
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
smooth: this.chartType === "line" ? 0.4 : false,
type: chartType,
smooth: chartType === "line" ? 0.4 : false,
cursor: "default",
data: [],
name: name
@@ -555,16 +561,23 @@ export class StatisticsChart extends LitElement {
width: 1.5,
},
itemStyle:
this.chartType === "bar"
chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
borderColor,
borderWidth: 1.5,
}
: undefined,
color: this.chartType === "bar" ? backgroundColor : borderColor,
color: chartType === "bar" ? backgroundColor : borderColor,
};
if (band && this.chartType === "line") {
if (chartStacked) {
series.stack = `band-stacked`;
series.stackStrategy = "samesign";
if (chartType === "line") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
} else if (band && chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
@@ -621,7 +634,7 @@ export class StatisticsChart extends LitElement {
}
} else if (
type === bandTop &&
this.chartType === "line" &&
chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
@@ -645,11 +658,9 @@ export class StatisticsChart extends LitElement {
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (this.chartType === "line" && lastEndTime && lastValues) {
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push(
this._transformDataValue([lastEndTime, ...lastValues[i]!])
);
d.data!.push([lastEndTime, ...lastValues[i]!]);
});
}
@@ -657,6 +668,7 @@ export class StatisticsChart extends LitElement {
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (
displayCurrentState &&
!chartStacked &&
(!this.unit || !statisticUnit || this.unit === statisticUnit)
) {
// Skip external statistics
@@ -677,7 +689,7 @@ export class StatisticsChart extends LitElement {
const val: (number | null)[] = [];
if (
type === bandTop &&
this.chartType === "line" &&
chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
@@ -687,9 +699,7 @@ export class StatisticsChart extends LitElement {
} else {
val.push(currentValue);
}
statDataSets[i].data!.push(
this._transformDataValue([now, ...val])
);
statDataSets[i].data!.push([now, ...val]);
});
}
}
@@ -701,6 +711,13 @@ export class StatisticsChart extends LitElement {
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "bar") {
fillDataGapsAndRoundCaps(
totalDataSets as BarSeriesOption[],
chartStacked
);
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({
@@ -710,7 +727,7 @@ export class StatisticsChart extends LitElement {
itemStyle: {
borderColor,
},
type: this.chartType,
type: chartType,
data: [],
xAxisIndex: 1,
});
@@ -728,13 +745,6 @@ export class StatisticsChart extends LitElement {
this._statisticIds = statisticIds;
}
private _transformDataValue(val: [Date, ...(number | null)[]]) {
if (this.chartType === "bar" && val[1] && val[1] < 0) {
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
}
return val;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value

View File

@@ -887,10 +887,20 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
private _handleRowCheckboxClicked = (
ev: HASSDomCurrentTargetEvent<HaCheckbox & { rowId: string }>
) => {
const rowId = ev.currentTarget.rowId;
private _handleRowCheckboxClicked = (ev: MouseEvent) => {
// ha-checkbox label dispatches synthetic click on input, so handle the input click only
if (!(ev.composedPath()[0] instanceof HTMLInputElement) && !ev.shiftKey) {
return;
}
// In range select mode, use label click for Firefox since it doesn't fire input click events
if (ev.composedPath()[0] instanceof HTMLInputElement && ev.shiftKey) {
ev.preventDefault();
}
const checkboxElement = ev.currentTarget as HaCheckbox & { rowId: string };
const rowId = checkboxElement.rowId;
const groupedData = this._groupData(
this._filteredData,
@@ -927,7 +937,7 @@ export class HaDataTable extends LitElement {
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!ev.currentTarget.checked) {
} else if (checkboxElement.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
}
@@ -1474,6 +1484,10 @@ export class HaDataTable extends LitElement {
lit-virtualizer:focus-visible {
outline: none;
}
ha-checkbox {
padding: var(--ha-space-1);
}
`,
];
}

View File

@@ -79,6 +79,8 @@ export const datePickerStyles = css`
flex: 1;
text-align: center;
margin-left: 48px;
margin-inline-start: 48px;
margin-inline-end: initial;
}
`;

View File

@@ -3,8 +3,9 @@ import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-ite
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../common/dom/fire_event";
import { preventDefault } from "../common/dom/prevent_default";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-checkbox";
@customElement("ha-check-list-item")
@@ -15,17 +16,15 @@ export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean })
indeterminate = false;
@property({ type: Boolean, attribute: "separate-checkbox-click" })
separateCheckboxClick = false;
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const checkboxClasses = {
"mdc-deprecated-list-item__graphic": this.left,
"mdc-deprecated-list-item__meta": !this.left,
};
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
@@ -35,17 +34,16 @@ export class HaCheckListItem extends CheckListItemBase {
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<span class=${classMap(checkboxClasses)}>
<ha-checkbox
reducedTouchTarget
tabindex=${this.tabindex}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
>
</ha-checkbox>
</span>
<ha-checkbox
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
class=${this.left ? "left" : ""}
>
</ha-checkbox>
${this.left ? text : ""} ${meta}`;
}
@@ -65,11 +63,16 @@ export class HaCheckListItem extends CheckListItemBase {
margin-inline-start: 0px;
direction: var(--direction);
}
.mdc-deprecated-list-item__meta {
ha-checkbox {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
height: 100%;
justify-content: center;
}
ha-checkbox.left {
margin-inline-start: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-list-item-graphic-margin-top);

View File

@@ -1,18 +1,156 @@
import { CheckboxBase } from "@material/mwc-checkbox/mwc-checkbox-base";
import { styles } from "@material/mwc-checkbox/mwc-checkbox.css";
import { css } from "lit";
import WaCheckbox from "@home-assistant/webawesome/dist/components/checkbox/checkbox";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant checkbox component
*
* @element ha-checkbox
* @extends {WaCheckbox}
*
* @summary
* A Home Assistant themed wrapper around the Web Awesome checkbox.
*
* @slot - The checkbox's label.
* @slot hint - Text that describes how to use the checkbox.
*
* @csspart base - The component's label wrapper.
* @csspart control - The square container that wraps the checkbox's checked state.
* @csspart checked-icon - The checked icon, a `<wa-icon>` element.
* @csspart indeterminate-icon - The indeterminate icon, a `<wa-icon>` element.
* @csspart label - The container that wraps the checkbox's label.
* @csspart hint - The hint's wrapper.
*
* @cssprop --ha-checkbox-size - The checkbox size. Defaults to `20px`.
* @cssprop --ha-checkbox-border-color - The border color of the checkbox control. Defaults to `--ha-color-border-neutral-normal`.
* @cssprop --ha-checkbox-border-color-hover - The border color of the checkbox control on hover. Defaults to `--ha-checkbox-border-color`, then `--ha-color-border-neutral-loud`.
* @cssprop --ha-checkbox-background-color - The background color of the checkbox control. Defaults to `--wa-form-control-background-color`.
* @cssprop --ha-checkbox-background-color-hover - The background color of the checkbox control on hover. Defaults to `--ha-color-form-background-hover`.
* @cssprop --ha-checkbox-checked-background-color - The background color when checked or indeterminate. Defaults to `--ha-color-fill-primary-loud-resting`.
* @cssprop --ha-checkbox-checked-background-color-hover - The background color when checked or indeterminate on hover. Defaults to `--ha-color-fill-primary-loud-hover`.
* @cssprop --ha-checkbox-checked-icon-color - The color of the checked and indeterminate icons. Defaults to `--wa-color-brand-on-loud`.
* @cssprop --ha-checkbox-checked-icon-scale - The size of the checked and indeterminate icons relative to the checkbox. Defaults to `0.9`.
* @cssprop --ha-checkbox-border-radius - The border radius of the checkbox control. Defaults to `--ha-border-radius-sm`.
* @cssprop --ha-checkbox-border-width - The border width of the checkbox control. Defaults to `--ha-border-width-md`.
* @cssprop --ha-checkbox-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
* @cssprop --ha-checkbox-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
*
* @attr {boolean} checked - Draws the checkbox in a checked state.
* @attr {boolean} disabled - Disables the checkbox.
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state.
* @attr {boolean} required - Makes the checkbox a required field.
*/
@customElement("ha-checkbox")
export class HaCheckbox extends CheckboxBase {
static override styles = [
styles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
`,
];
export class HaCheckbox extends WaCheckbox {
/**
* Returns the configured checkbox value, independent of checked state.
*
* The base Web Awesome checkbox returns `null` when unchecked to align with
* form submission rules. Home Assistant components expect the configured value
* to remain readable, so this wrapper always exposes the internal value.
*/
// @ts-ignore - accessing WA internal _value property
override get value(): string | null {
// @ts-ignore
return this._value ?? null;
}
/** Sets the configured checkbox value. */
override set value(val: string | null) {
// @ts-ignore
this._value = val;
}
static get styles(): CSSResultGroup {
return [
WaCheckbox.styles,
css`
:host {
--wa-form-control-toggle-size: var(--ha-checkbox-size, 20px);
--wa-form-control-border-color: var(
--ha-checkbox-border-color,
var(--ha-color-border-neutral-normal)
);
--wa-form-control-background-color: var(
--ha-checkbox-background-color,
var(--wa-form-control-background-color)
);
--checked-icon-color: var(
--ha-checkbox-checked-icon-color,
var(--wa-color-brand-on-loud)
);
--wa-form-control-activated-color: var(
--ha-checkbox-checked-background-color,
var(--ha-color-fill-primary-loud-resting)
);
-webkit-tap-highlight-color: transparent;
--checked-icon-scale: var(--ha-checkbox-checked-icon-scale, 0.9);
--wa-form-control-required-content: var(
--ha-checkbox-required-marker,
var(--ha-input-required-marker, "*")
);
--wa-form-control-required-content-offset: var(
--ha-checkbox-required-marker-offset,
0.1rem
);
}
[part~="base"] {
align-items: center;
gap: var(--ha-space-2);
}
[part~="control"] {
border-radius: var(
--ha-checkbox-border-radius,
var(--ha-border-radius-sm)
);
border-width: var(
--ha-checkbox-border-width,
var(--ha-border-width-md)
);
margin-inline-end: 0;
}
[part~="label"] {
line-height: 1;
}
#hint {
font-size: var(--ha-font-size-xs);
color: var(--ha-color-text-secondary);
}
label:has(input:not(:disabled)):hover {
--wa-form-control-border-color: var(
--ha-checkbox-border-color-hover,
var(--ha-checkbox-border-color, var(--ha-color-border-neutral-loud))
);
}
label:has(input:not(:disabled)):hover [part~="control"] {
background-color: var(
--ha-checkbox-background-color-hover,
var(--ha-color-form-background-hover)
);
}
label:has(input:checked:not(:disabled)):hover [part~="control"],
label:has(input:indeterminate:not(:disabled)):hover [part~="control"] {
background-color: var(
--ha-checkbox-checked-background-color-hover,
var(--ha-color-fill-primary-loud-hover)
);
border-color: var(
--ha-checkbox-checked-background-color-hover,
var(--ha-color-fill-primary-loud-hover)
);
}
`,
];
}
}
declare global {

View File

@@ -10,6 +10,7 @@ import type {
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import type { Extension, TransactionSpec } from "@codemirror/state";
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
import type { SyntaxNode } from "@lezer/common";
import { placeholder } from "@codemirror/view";
import {
mdiArrowCollapse,
@@ -26,13 +27,20 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, ReactiveElement, render } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import type { JinjaArgType } from "../resources/jinja_ha_completions";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
@@ -109,6 +117,10 @@ export class HaCodeEditor extends ReactiveElement {
@state() private _canCopy = false;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
@@ -204,9 +216,6 @@ export class HaCodeEditor extends ReactiveElement {
transactions.push({
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.foldingCompartment.reconfigure(
this._getFoldingExtensions()
),
],
});
}
@@ -273,6 +282,8 @@ export class HaCodeEditor extends ReactiveElement {
}
const extensions: Extension[] = [
this._loadedCodeMirror.lineNumbers(),
this._loadedCodeMirror.foldGutter(),
this._loadedCodeMirror.bracketMatching(),
this._loadedCodeMirror.history(),
this._loadedCodeMirror.drawSelection(),
this._loadedCodeMirror.EditorState.allowMultipleSelections.of(true),
@@ -290,6 +301,9 @@ export class HaCodeEditor extends ReactiveElement {
},
}),
this._loadedCodeMirror.keymap.of([
// closeBracketsKeymap must come before defaultKeymap so its Backspace
// handler runs before the default delete-character binding.
...(!this.readOnly ? this._loadedCodeMirror.closeBracketsKeymap : []),
...this._loadedCodeMirror.defaultKeymap,
...this._loadedCodeMirror.searchKeymap,
...this._loadedCodeMirror.historyKeymap,
@@ -300,6 +314,8 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.langCompartment.of(this._mode),
this._loadedCodeMirror.haTheme,
this._loadedCodeMirror.haSyntaxHighlighting,
this._loadedCodeMirror.yamlScalarHighlighter,
this._loadedCodeMirror.yamlScalarHighlightStyle,
this._loadedCodeMirror.readonlyCompartment.of(
this._loadedCodeMirror.EditorView.editable.of(!this.readOnly)
),
@@ -307,9 +323,6 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.foldingCompartment.of(
this._getFoldingExtensions()
),
this._loadedCodeMirror.tooltips({
position: "absolute",
}),
@@ -317,21 +330,24 @@ export class HaCodeEditor extends ReactiveElement {
];
if (!this.readOnly) {
const completionSources: CompletionSource[] = [];
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.autocompleteEntities && this.hass) {
completionSources.push(this._entityCompletions.bind(this));
}
if (this.autocompleteIcons) {
completionSources.push(this._mdiCompletions.bind(this));
}
if (completionSources.length > 0) {
extensions.push(
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
})
);
}
extensions.push(
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
this._loadedCodeMirror.closePercentBrace
);
}
// Create the code editor
@@ -559,7 +575,10 @@ export class HaCodeEditor extends ReactiveElement {
};
private _renderInfo = (completion: Completion): CompletionInfo => {
const key = completion.label;
const key =
typeof completion.apply === "string"
? completion.apply
: completion.label;
const context = getEntityContext(
this.hass!.states[key],
this.hass!.entities,
@@ -620,10 +639,62 @@ export class HaCodeEditor extends ReactiveElement {
return completionInfo;
};
private _renderAttributeInfo = (
entityId: string,
attribute: string
): CompletionInfo | null => {
if (!this.hass) return null;
const stateObj = this.hass.states[entityId];
if (!stateObj) return null;
const translatedName = this.hass.formatEntityAttributeName(
stateObj,
attribute
);
const formattedValue = this.hass.formatEntityAttributeValue(
stateObj,
attribute
);
const rawValue = stateObj.attributes[attribute];
const rawValueStr =
rawValue !== null && rawValue !== undefined
? String(rawValue)
: undefined;
const completionItems: CompletionItem[] = [
{
label: translatedName,
value: formattedValue,
// Show raw value as sub-value only when it differs from the formatted one
subValue:
rawValueStr !== undefined && rawValueStr !== formattedValue
? rawValueStr
: undefined,
},
];
const completionInfo = document.createElement("div");
completionInfo.classList.add("completion-info");
render(
html`
<ha-code-editor-completion-items
.items=${completionItems}
></ha-code-editor-completion-items>
`,
completionInfo
);
return completionInfo;
};
private _getCompletionInfo = (
completion: Completion
): CompletionInfo | Promise<CompletionInfo> | null => {
if (this.hass && completion.label in this.hass.states) {
if (
this.hass &&
typeof completion.apply === "string" &&
completion.apply in this.hass.states
) {
return this._renderInfo(completion);
}
@@ -631,6 +702,11 @@ export class HaCodeEditor extends ReactiveElement {
return renderIcon(completion);
}
// Attribute completions attach an info function directly on the object.
if (typeof completion.info === "function") {
return completion.info(completion);
}
return null;
};
@@ -778,16 +854,546 @@ export class HaCodeEditor extends ReactiveElement {
const options = Object.keys(states).map((key) => ({
type: "variable",
label: key,
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
private get _jinjaFunctionArgTypes() {
return this._loadedCodeMirror!.JINJA_FUNCTION_ARG_TYPES;
}
// The accessible properties on TemplateStateBase (from HA core source).
// These are valid completions at `states.<domain>.<entity>.___`.
private static readonly _STATE_FIELDS: string[] = [
"state",
"attributes",
"last_changed",
"last_updated",
"context",
"domain",
"object_id",
"name",
"entity_id",
"state_with_unit",
];
/**
* Handles `states.<domain>.<entity>.<field>.<attr>` dot-notation completions.
*
* Walks the MemberExpression chain in the Jinja syntax tree rooted at the
* `states` VariableName and offers completions depending on depth:
* - `states.` → all domains
* - `states.<d>.` → all entity object_ids for that domain
* - `states.<d>.<e>.` → fixed state fields
* - `states.<d>.<e>.attributes.` → attribute names from hass.states
*
* Returns undefined to fall through when the cursor is not inside a
* `states.` chain; returns null/CompletionResult to short-circuit.
*/
private _statesDotNotationCompletions(
context: CompletionContext
): CompletionResult | null | undefined {
if (!this.hass) return undefined;
const { state: editorState, pos } = context;
const tree = this._loadedCodeMirror!.syntaxTree(editorState);
const node = tree.resolveInner(pos, -1);
// We act on two cursor positions:
// (a) cursor is ON a PropertyName node → partially typed identifier
// (b) cursor is on/just-after a "." node → right after the dot
// In both cases the parent is a MemberExpression.
const memberNode = node.parent;
// "from" for the completion result (start of what the user is currently typing)
let completionFrom = pos;
if (
node.name === "PropertyName" &&
memberNode?.name === "MemberExpression"
) {
// Cursor is on a PropertyName — replace from start of that name.
completionFrom = node.from;
} else if (node.name === "." && memberNode?.name === "MemberExpression") {
// Cursor just after "." — insert from current position.
completionFrom = pos;
} else {
return undefined;
}
// Walk up the MemberExpression chain to collect property segments and
// find the root VariableName.
//
// Each MemberExpression has the shape: <object> "." <PropertyName>
// so the last PropertyName in the chain is the one directly under the
// outermost member expression. We walk *up* to find the root, collecting
// each intermediate PropertyName text along the way.
//
// Example for states.light.living_room.attributes at cursor after the
// last dot:
// MemberExpression <- memberNode (cursor's parent)
// MemberExpression <- depth 3 (states.light.living_room)
// MemberExpression <- depth 2 (states.light)
// VariableName "states"
// "."
// PropertyName "light"
// "."
// PropertyName "living_room"
// "."
// (no PropertyName yet — cursor is right here)
// Collect the segments bottom-up (innermost first).
const segments: string[] = [];
let cur = memberNode; // the MemberExpression directly containing the cursor
// If cursor is on a PropertyName, that is part of *this* MemberExpression;
// skip it — we don't want to include what the user is currently typing.
// We want the segments that lead *up to* the current position.
// Walk up through parent MemberExpressions collecting PropertyName texts.
// Each MemberExpression's last PropertyName child is the segment for that
// level — but we skip the innermost one if the cursor is on a PropertyName
// (that's the partial input, not a committed segment).
let skipFirst = node.name === "PropertyName";
while (cur?.name === "MemberExpression") {
// The PropertyName child of this MemberExpression is its rightmost segment.
let propChild = cur.lastChild;
while (propChild && propChild.name !== "PropertyName") {
propChild = propChild.prevSibling;
}
if (propChild) {
if (skipFirst) {
skipFirst = false;
} else {
segments.unshift(
editorState.doc.sliceString(propChild.from, propChild.to)
);
}
}
// The object side is the first child of the MemberExpression
const objectChild = cur.firstChild;
if (!objectChild) break;
if (objectChild.name === "VariableName") {
// Check if this is the root "states" variable
const varName = editorState.doc.sliceString(
objectChild.from,
objectChild.to
);
if (varName !== "states") return undefined; // not a states chain
break; // found root
}
if (objectChild.name !== "MemberExpression") return undefined;
cur = objectChild;
}
// Verify we actually found a root VariableName "states" (cur must be a
// MemberExpression whose first child is VariableName "states").
const rootObject = cur?.firstChild;
if (!rootObject || rootObject.name !== "VariableName") return undefined;
if (
editorState.doc.sliceString(rootObject.from, rootObject.to) !== "states"
) {
return undefined;
}
const depth = segments.length; // number of segments already committed
switch (depth) {
case 0: {
// states. → offer all unique domains
const domains = [
...new Set(
Object.keys(this.hass.states).map((id) => id.split(".")[0])
),
].sort();
return {
from: completionFrom,
options: domains.map((d) => ({ label: d, type: "variable" })),
validFor: /^\w*$/,
};
}
case 1: {
// states.<domain>. → offer entity object_ids for that domain
const [domain] = segments;
const entities = Object.keys(this.hass.states)
.filter((id) => id.startsWith(`${domain}.`))
.map((id) => id.split(".").slice(1).join("."));
if (!entities.length) return { from: completionFrom, options: [] };
return {
from: completionFrom,
options: entities.map((e) => ({ label: e, type: "variable" })),
validFor: /^\w*$/,
};
}
case 2: {
// states.<domain>.<entity>. → fixed state fields
return {
from: completionFrom,
options: HaCodeEditor._STATE_FIELDS.map((f) => ({
label: f,
type: "property",
})),
validFor: /^\w*$/,
};
}
case 3: {
// states.<domain>.<entity>.<field>.
const [domain, entity, field] = segments;
if (field !== "attributes") {
// No further completions for non-attribute fields
return { from: completionFrom, options: [] };
}
// Offer attribute names from the entity's state object
const entityId = `${domain}.${entity}`;
const entityState = this.hass.states[entityId];
if (!entityState) return { from: completionFrom, options: [] };
const attrNames = Object.keys(entityState.attributes).sort();
return {
from: completionFrom,
options: attrNames.map((a) => ({ label: a, type: "property" })),
validFor: /^\w*$/,
};
}
default:
// Depth ≥ 4 — no further completions
return { from: completionFrom, options: [] };
}
}
/**
* Returns completions when inside a quoted Jinja string argument of a known
* HA function, or inside a states['...'] subscript.
* Returns undefined to signal the caller should fall through to other logic.
*/
private _jinjaStringArgCompletions(
context: CompletionContext
): CompletionResult | null | undefined {
const { state: editorState, pos } = context;
const node = this._loadedCodeMirror!.syntaxTree(editorState).resolveInner(
pos,
-1
);
// Must be inside a StringLiteral
if (node.name !== "StringLiteral") return undefined;
// Case 1: states['entity_id'] — StringLiteral inside SubscriptExpression
// whose object is the `states` variable.
const subscript = node.parent;
if (subscript?.name === "SubscriptExpression") {
const obj = subscript.firstChild;
if (obj && editorState.doc.sliceString(obj.from, obj.to) === "states") {
return this._completionResultForArgType("entity_id", node);
}
}
// Case 2: string argument of a known HA function call.
const argList = node.parent;
if (argList?.name !== "ArgumentList") return undefined;
const callExpr = argList.parent;
if (callExpr?.name !== "CallExpression") return undefined;
const fnNode = callExpr.firstChild;
if (!fnNode) return undefined;
const fnName = editorState.doc.sliceString(fnNode.from, fnNode.to);
const argTypeMap = this._jinjaFunctionArgTypes.get(fnName);
if (!argTypeMap) return undefined;
// Walk ArgumentList children to find the zero-based index of this node.
// Children are: "(" arg0 "," arg1 "," arg2 ... ")" — skip punctuation.
let argIndex = 0;
let child = argList.firstChild?.nextSibling; // skip opening "("
while (child) {
if (child.name === ")") break;
if (child.name !== ",") {
if (child.from === node.from) break;
argIndex++;
}
child = child.nextSibling;
}
const argType = argTypeMap.get(argIndex);
if (!argType) return undefined;
// For attribute completions, try to resolve the entity_id from the
// sibling argument whose type is entity_id in the same call.
if (argType === "attribute") {
const entityId = this._entityIdFromSiblingArg(
argList,
argTypeMap,
editorState
);
return this._attributeCompletionResult(node, entityId);
}
return this._completionResultForArgType(argType, node);
}
/**
* Scans the ArgumentList for the first argument whose type is `entity_id`
* and returns the literal string value it contains, or null if not found /
* not a plain string literal.
*/
private _entityIdFromSiblingArg(
argList: SyntaxNode,
argTypeMap: Map<number, JinjaArgType>,
editorState: CompletionContext["state"]
): string | null {
// Find the index of the entity_id argument in the type map.
let entityArgIndex: number | undefined;
for (const [idx, type] of argTypeMap) {
if (type === "entity_id") {
entityArgIndex = idx;
break;
}
}
if (entityArgIndex === undefined) return null;
// Walk the ArgumentList to find that argument node.
let idx = 0;
let child = argList.firstChild?.nextSibling; // skip "("
while (child) {
if (child.name === ")") break;
if (child.name !== ",") {
if (idx === entityArgIndex) {
// child should be a StringLiteral — extract its content without quotes.
if (child.name !== "StringLiteral") return null;
const raw = editorState.doc.sliceString(child.from, child.to);
// Strip surrounding quote character (single or double).
return raw.slice(1, -1);
}
idx++;
}
child = child.nextSibling;
}
return null;
}
/**
* Dispatches to the appropriate completion result builder for the given
* argument type. Add new cases here as completion sources are implemented.
*
* Always returns a CompletionResult (never null) so that other completion
* sources are suppressed when the cursor is inside a known typed string arg.
* An empty options list is returned when no completions are available.
*/
private _completionResultForArgType(
argType: JinjaArgType,
stringNode: { from: number; to: number }
): CompletionResult {
const from = stringNode.from + 1;
const empty: CompletionResult = { from, options: [] };
switch (argType) {
case "entity_id":
return this._entityCompletionResult(stringNode) ?? empty;
case "device_id":
return this._deviceCompletionResult(stringNode) ?? empty;
case "area_id":
return this._areaCompletionResult(stringNode) ?? empty;
case "floor_id":
return this._floorCompletionResult(stringNode) ?? empty;
case "label_id":
return this._labelCompletionResult(stringNode) ?? empty;
case "attribute":
// No entity context available — return empty to suppress other sources.
return empty;
default:
return empty;
}
}
/**
* Build a CompletionResult for attribute names of a specific entity.
* `entityId` may be null when the sibling entity arg is not yet filled in,
* in which case an empty result is returned (other sources stay suppressed).
*/
private _attributeCompletionResult(
stringNode: { from: number; to: number },
entityId: string | null
): CompletionResult {
const from = stringNode.from + 1;
const empty: CompletionResult = { from, options: [] };
if (!entityId || !this.hass) return empty;
const entityState = this.hass.states[entityId];
if (!entityState) return empty;
const attrs = Object.keys(entityState.attributes).sort();
if (!attrs.length) return empty;
return {
from,
options: attrs.map((a) => ({
label: a,
type: "property",
info: () => this._renderAttributeInfo(entityId, a),
})),
validFor: /^[\w.]*$/,
};
}
/** Build a CompletionResult for entity IDs, with `from` set inside the quotes. */
private _entityCompletionResult(stringNode: {
from: number;
to: number;
}): CompletionResult | null {
const states = this._getStates(this.hass!.states);
if (!states?.length) return null;
// from is stringNode.from + 1 to skip the opening quote character.
const from = stringNode.from + 1;
// Always offer completions inside a known entity-string context, including
// immediately after the opening quote (e.g. after snippet insertion).
return {
from,
options: states,
validFor: /^[\w.]*$/,
};
}
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
private _deviceCompletionResult(stringNode: {
from: number;
to: number;
}): CompletionResult | null {
if (!this.hass?.devices) return null;
const devices = this._getDevices(this.hass.devices);
if (!devices.length) return null;
return {
from: stringNode.from + 1,
options: devices,
validFor: /^[^"]*$/,
};
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
private _areaCompletionResult(stringNode: {
from: number;
to: number;
}): CompletionResult | null {
if (!this.hass?.areas) return null;
const areas = this._getAreas(this.hass.areas);
if (!areas.length) return null;
return {
from: stringNode.from + 1,
options: areas,
validFor: /^[^"]*$/,
};
}
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
private _floorCompletionResult(stringNode: {
from: number;
to: number;
}): CompletionResult | null {
if (!this.hass?.floors) return null;
const floors = this._getFloors(this.hass.floors);
if (!floors.length) return null;
return {
from: stringNode.from + 1,
options: floors,
validFor: /^[^"]*$/,
};
}
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
private _labelCompletionResult(stringNode: {
from: number;
to: number;
}): CompletionResult | null {
if (!this._labels?.length) return null;
const labels = this._getLabels(this._labels);
if (!labels.length) return null;
return {
from: stringNode.from + 1,
options: labels,
validFor: /^[^"]*$/,
};
}
private _entityCompletions(
context: CompletionContext
): CompletionResult | null | Promise<CompletionResult | null> {
// Jinja context: offer entity completions inside string arguments of
// entity-accepting functions, and inside states['...'] subscripts.
if (this.mode === "yaml" || this.mode === "jinja2") {
// First try states.<domain>.<entity>.<field> dot-notation completions.
const statesDotResult = this._statesDotNotationCompletions(context);
if (statesDotResult !== undefined) {
return statesDotResult;
}
const jinjaEntityResult = this._jinjaStringArgCompletions(context);
if (jinjaEntityResult !== undefined) {
return jinjaEntityResult;
}
}
// Check for YAML mode and entity-related fields
if (this.mode === "yaml") {
const currentLine = context.state.doc.lineAt(context.pos);
@@ -819,8 +1425,16 @@ export class HaCodeEditor extends ReactiveElement {
const listItemMatch = lineText.match(/^\s*-\s+/);
if (entityFieldMatch) {
// Calculate the position after the entity field
// Calculate the position after the entity field key+colon.
// The regex consumes trailing spaces too, so afterField lands right
// where the entity ID should start. If the cursor is sitting directly
// after the colon with no space (e.g. "entity:|"), we need to insert
// a space before the entity ID, so we shift `from` back to the colon
// and use an `apply` that prepends the space.
const afterField = currentLine.from + entityFieldMatch[0].length;
const needsSpace =
afterField > 0 &&
context.state.doc.sliceString(afterField - 1, afterField) === ":";
// If cursor is after the entity field, show all entities
if (context.pos >= afterField) {
@@ -842,9 +1456,13 @@ export class HaCodeEditor extends ReactiveElement {
)
: states;
const options = needsSpace
? filteredStates.map((s) => ({ ...s, apply: ` ${s.label}` }))
: filteredStates;
return {
from: afterField,
options: filteredStates,
options,
validFor: /^[a-z_]*\.?\w*$/,
};
}
@@ -919,7 +1537,13 @@ export class HaCodeEditor extends ReactiveElement {
}
}
// Original entity completion logic for non-YAML or when not in entity_id field
// Original entity completion logic for non-YAML or when not in entity_id field.
// Not used in jinja2 mode — Jinja string-arg completions are handled above via
// _jinjaStringArgCompletions() which is context-aware.
if (this.mode === "jinja2") {
return null;
}
const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/);
if (
@@ -989,17 +1613,6 @@ export class HaCodeEditor extends ReactiveElement {
fireEvent(this, "value-changed", { value: this._value });
};
private _getFoldingExtensions = (): Extension => {
if (this.mode === "yaml") {
return [
this._loadedCodeMirror!.foldGutter(),
this._loadedCodeMirror!.foldingOnIndent,
];
}
return [];
};
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,

View File

@@ -84,6 +84,7 @@ export class HaFilterDevices extends LitElement {
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>`
@@ -98,6 +99,7 @@ export class HaFilterDevices extends LitElement {
!device
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${device.id}
.selected=${this.value?.includes(device.id) ?? false}
>
@@ -108,6 +110,13 @@ export class HaFilterDevices extends LitElement {
)}
</ha-check-list-item>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;

View File

@@ -58,7 +58,7 @@ export class HaFilterDomains extends LitElement {
</ha-input-search>
<ha-list
class="ha-scrollbar"
@click=${this._handleItemClick}
@selected=${this._handleItemSelected}
multi
>
${repeat(
@@ -126,19 +126,16 @@ export class HaFilterDomains extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
private _handleItemSelected(
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
) {
const domains = this._domains(this.hass.states, this._filter);
if (ev.detail.diff.added.length) {
this.value = [...(this.value || []), domains[ev.detail.diff.added[0]]];
} else if (ev.detail.diff.removed.length) {
const removedDomain = domains[ev.detail.diff.removed[0]];
this.value = this.value?.filter((value) => value !== removedDomain);
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value.includes(value);
fireEvent(this, "data-table-filter-changed", {
value: this.value,

View File

@@ -88,6 +88,7 @@ export class HaFilterEntities extends LitElement {
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>
@@ -116,6 +117,7 @@ export class HaFilterEntities extends LitElement {
!entity
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${entity.entity_id}
.selected=${this.value?.includes(entity.entity_id) ?? false}
graphic="icon"
@@ -128,6 +130,13 @@ export class HaFilterEntities extends LitElement {
${computeStateName(entity)}
</ha-check-list-item>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;

View File

@@ -88,6 +88,7 @@ export class HaFilterFloorAreas extends LitElement {
) || false}
graphic="icon"
@request-selected=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
<ha-floor-icon
slot="graphic"
@@ -125,6 +126,7 @@ export class HaFilterFloorAreas extends LitElement {
.type=${"areas"}
graphic="icon"
@request-selected=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
class=${classMap({
rtl: computeRTL(this.hass),
floor: hasFloor,
@@ -149,6 +151,13 @@ export class HaFilterFloorAreas extends LitElement {
`;
}
private _handleItemKeydown(ev) {
if (ev.key === " " || ev.key === "Enter") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
ev.stopPropagation();

View File

@@ -61,7 +61,7 @@ export class HaFilterIntegrations extends LitElement {
</ha-input-search>
<ha-list
class="ha-scrollbar"
@click=${this._handleItemClick}
@selected=${this._itemSelected}
multi
>
${repeat(
@@ -147,18 +147,25 @@ export class HaFilterIntegrations extends LitElement {
)
);
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
private _itemSelected(
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
) {
const integrations = this._integrations(
this.hass.localize,
this._manifests!,
this._filter,
this.value
);
if (ev.detail.diff.added.length) {
this.value = [
...(this.value || []),
integrations[ev.detail.diff.added[0]].domain,
];
} else if (ev.detail.diff.removed.length) {
const removedDomain = integrations[ev.detail.diff.removed[0]].domain;
this.value = this.value?.filter((val) => val !== removedDomain);
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
fireEvent(this, "data-table-filter-changed", {
value: this.value,

View File

@@ -79,6 +79,7 @@ export const computeInitialHaFormData = (
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||

View File

@@ -1,15 +1,14 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import type {
HaFormBooleanData,
HaFormBooleanSchema,
HaFormElement,
} from "./types";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-checkbox";
import "../ha-formfield";
@customElement("ha-form-boolean")
export class HaFormBoolean extends LitElement implements HaFormElement {
@@ -33,19 +32,14 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
<ha-formfield .label=${this.label}>
<ha-checkbox
.checked=${this.data}
.disabled=${this.disabled}
@change=${this._valueChanged}
></ha-checkbox>
<span slot="label">
<p class="primary">${this.label}</p>
${this.helper
? html`<p class="secondary">${this.helper}</p>`
: nothing}
</span>
</ha-formfield>
<ha-checkbox
.checked=${this.data}
.disabled=${this.disabled}
@change=${this._valueChanged}
.hint=${this.helper}
>
${this.label}
</ha-checkbox>
`;
}
@@ -56,25 +50,12 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
}
static styles = css`
ha-formfield {
display: flex;
ha-checkbox {
min-height: 56px;
justify-content: center;
}
ha-checkbox::part(base) {
align-items: center;
--mdc-typography-body2-font-size: 1em;
}
p {
margin: 0;
}
.secondary {
direction: var(--direction);
padding-top: 4px;
box-sizing: border-box;
color: var(--secondary-text-color);
font-size: 0.875rem;
font-weight: var(
--mdc-typography-body2-font-weight,
var(--ha-font-weight-normal)
);
}
`;
}

View File

@@ -199,10 +199,15 @@ export class HaFormInteger extends LitElement implements HaFormElement {
}
.flex {
display: flex;
align-items: center;
gap: var(--ha-space-3);
}
ha-slider {
flex: 1;
}
ha-input-helper-text {
margin-top: var(--ha-space-1);
}
`;
}

View File

@@ -7,7 +7,6 @@ import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-picker-field";
@@ -63,14 +62,14 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
${this.label}${options.map((item: string | [string, string]) => {
const value = optionValue(item);
return html`
<ha-formfield .label=${optionLabel(item)}>
<ha-checkbox
.checked=${data.includes(value)}
.value=${value}
.disabled=${this.disabled}
@change=${this._valueChanged}
></ha-checkbox>
</ha-formfield>
<ha-checkbox
.checked=${data.includes(value)}
.value=${value}
.disabled=${this.disabled}
@change=${this._valueChanged}
>
${optionLabel(item)}
</ha-checkbox>
`;
})}
</div> `;
@@ -192,11 +191,12 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
ha-dropdown {
display: block;
}
ha-formfield {
display: block;
padding-right: 16px;
padding-inline-end: 16px;
ha-checkbox {
display: flex;
padding-inline-end: var(--ha-space-4);
padding-inline-start: initial;
min-height: 40px;
justify-content: center;
direction: var(--direction);
}
ha-icon-button {

View File

@@ -46,6 +46,18 @@ export class HaGauge extends LitElement {
@state() private _segment_label?: string = "";
private _sortedLevels?: LevelDefinition[];
private _rescaleOnConnect = false;
public connectedCallback(): void {
super.connectedCallback();
if (this._rescaleOnConnect) {
this._rescaleSvg();
this._rescaleOnConnect = false;
}
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
afterNextRender(() => {
@@ -58,6 +70,26 @@ export class HaGauge extends LitElement {
});
}
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("levels") || changedProperties.has("min")) {
if (this.levels) {
this._sortedLevels = [...this.levels].sort((a, b) => a.level - b.level);
if (
this._sortedLevels.length > 0 &&
this._sortedLevels[0].level !== this.min
) {
this._sortedLevels.unshift({
level: this.min,
stroke: "var(--info-color)",
});
}
} else {
this._sortedLevels = undefined;
}
}
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (
@@ -90,88 +122,61 @@ export class HaGauge extends LitElement {
/>
${
this.levels
? (() => {
const sortedLevels = [...this.levels].sort(
(a, b) => a.level - b.level
);
${this._sortedLevels?.map((level, i, arr) => {
const startLevel = level.level;
const endLevel = i + 1 < arr.length ? arr[i + 1].level : this.max;
if (
sortedLevels.length > 0 &&
sortedLevels[0].level !== this.min
) {
sortedLevels.unshift({
level: this.min,
stroke: "var(--info-color)",
});
}
const startAngle = getAngle(startLevel, this.min, this.max);
const endAngle = getAngle(endLevel, this.min, this.max);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
return sortedLevels.map((level, i, arr) => {
const startLevel = level.level;
const endLevel =
i + 1 < arr.length ? arr[i + 1].level : this.max;
const x1 = -arcRadius * Math.cos((startAngle * Math.PI) / 180);
const y1 = -arcRadius * Math.sin((startAngle * Math.PI) / 180);
const startAngle = getAngle(startLevel, this.min, this.max);
const endAngle = getAngle(endLevel, this.min, this.max);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
const isFirst = i === 0;
const isLast = i === arr.length - 1;
const x1 =
-arcRadius * Math.cos((startAngle * Math.PI) / 180);
const y1 =
-arcRadius * Math.sin((startAngle * Math.PI) / 180);
const x2 = -arcRadius * Math.cos((endAngle * Math.PI) / 180);
const y2 = -arcRadius * Math.sin((endAngle * Math.PI) / 180);
if (isFirst) {
return svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 40 0"
/>
`;
}
const isFirst = i === 0;
const isLast = i === arr.length - 1;
if (isLast) {
const offsetAngle = 0.5;
const midAngle = endAngle - offsetAngle;
const xm = -arcRadius * Math.cos((midAngle * Math.PI) / 180);
const ym = -arcRadius * Math.sin((midAngle * Math.PI) / 180);
if (isFirst) {
return svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
/>
`;
}
return svg`
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 40 0" />
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 40 0" />
`;
}
if (isLast) {
const offsetAngle = 0.5;
const midAngle = endAngle - offsetAngle;
const xm =
-arcRadius * Math.cos((midAngle * Math.PI) / 180);
const ym =
-arcRadius * Math.sin((midAngle * Math.PI) / 180);
return svg`
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${xm} ${ym}" />
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 ${x2} ${y2}" />
`;
}
return svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
></path>
`;
});
})()
: ""
}
return svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 40 0"
></path>
`;
})}
${
this.needle
? svg`
<path
class="needle"
d="M -34,-3 L -48,-1 A 1,1,0,0,0,-48,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
d="M -34,-3 L -40,-1 A 1,1,0,0,0,-40,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
/>
@@ -215,6 +220,13 @@ export class HaGauge extends LitElement {
// Set the viewbox of the SVG containing the value to perfectly
// fit the text
// That way it will auto-scale correctly
if (!this.isConnected) {
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
this._rescaleOnConnect = true;
return;
}
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
@@ -224,11 +236,10 @@ export class HaGauge extends LitElement {
}
private _getSegmentLabel() {
if (this.levels) {
[...this.levels].sort((a, b) => a.level - b.level);
for (let i = this.levels.length - 1; i >= 0; i--) {
if (this.value >= this.levels[i].level) {
return this.levels[i].label;
if (this._sortedLevels) {
for (let i = this._sortedLevels.length - 1; i >= 0; i--) {
if (this.value >= this._sortedLevels[i].level) {
return this._sortedLevels[i].label;
}
}
}

View File

@@ -30,6 +30,19 @@ const debouncedWriteCache = debounce(() => writeCache(chunks), 2000);
const cachedIcons: Record<string, string> = {};
const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
"home-assistant": () =>
import("../resources/home-assistant-logo-svg").then(
(mod) => mod.mdiHomeAssistant
),
"music-assistant": () =>
import("../resources/music-assistant-logo-svg").then(
(mod) => mod.mdiMusicAssistant
),
esphome: () =>
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
};
@customElement("ha-icon")
export class HaIcon extends LitElement {
@property() public icon?: string;
@@ -117,10 +130,8 @@ export class HaIcon extends LitElement {
return;
}
if (iconName === "home-assistant") {
const icon = (await import("../resources/home-assistant-logo-svg"))
.mdiHomeAssistant;
if (iconName in CUSTOM_ICONS) {
const icon = await CUSTOM_ICONS[iconName]();
if (this.icon === requestedIcon) {
this._path = icon;
}

View File

@@ -1,14 +1,19 @@
import Fuse from "fuse.js";
import { mdiDevices, mdiTextureBox } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
import { subscribeOneCollection } from "../common/util/subscribe-one";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { titleCase } from "../common/string/title-case";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { getIngressPanelInfoCollection } from "../data/hassio/ingress";
import { fetchConfig } from "../data/lovelace/config/types";
import { getPanelIcon, getPanelTitle, SYSTEM_PANELS } from "../data/panel";
import { SYSTEM_PANELS } from "../data/panel";
import {
CONFIG_SUB_ROUTES,
computeNavigationPathInfo,
} from "../data/compute-navigation-path-info";
import { findRelated, type RelatedResult } from "../data/search";
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
@@ -23,7 +28,12 @@ import {
type PickerComboBoxItem,
} from "./ha-picker-combo-box";
type NavigationGroup = "related" | "dashboards" | "views" | "other_routes";
type NavigationGroup =
| "related"
| "dashboards"
| "views"
| "apps"
| "other_routes";
const RELATED_SORT_PREFIX = {
area_view: "0_area_view",
@@ -31,6 +41,12 @@ const RELATED_SORT_PREFIX = {
device: "2_device",
} as const;
const createSortingLabel = (...parts: (string | undefined)[]) =>
parts
.filter(Boolean)
.map((part) => (part!.startsWith("/") ? `zzz${part}` : part))
.join("_");
interface NavigationItem extends PickerComboBoxItem {
group: NavigationGroup;
domain?: string;
@@ -50,6 +66,10 @@ export class HaNavigationPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: false }) public excludePaths?: string[];
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@state() private _loading = true;
@property({ attribute: false }) public context?: ActionRelatedContext;
@@ -66,6 +86,7 @@ export class HaNavigationPicker extends LitElement {
related: [],
dashboards: [],
views: [],
apps: [],
other_routes: [],
};
@@ -95,6 +116,14 @@ export class HaNavigationPicker extends LitElement {
id: "views",
label: this.hass.localize("ui.components.navigation-picker.views"),
},
...(this._navigationGroups.apps.length
? [
{
id: "apps",
label: this.hass.localize("ui.components.navigation-picker.apps"),
},
]
: []),
{
id: "other_routes",
label: this.hass.localize(
@@ -119,6 +148,7 @@ export class HaNavigationPicker extends LitElement {
.customValueLabel=${this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
)}
.addButtonLabel=${this.addButtonLabel}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -186,17 +216,28 @@ export class HaNavigationPicker extends LitElement {
views: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
apps: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
other_routes: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
};
private _getItems = (searchString?: string, section?: string) => {
const excludeSet = this.excludePaths
? new Set(this.excludePaths)
: undefined;
const getGroupItems = (group: NavigationGroup) => {
let items = [...this._navigationGroups[group]].sort(
this._sortBySortingLabel
);
if (excludeSet) {
items = items.filter((item) => !excludeSet.has(item.id));
}
if (searchString) {
const fuseIndex = this._fuseIndexes[group](items);
items = multiTermSortedSearch(
@@ -216,6 +257,7 @@ export class HaNavigationPicker extends LitElement {
const related = getGroupItems("related");
const dashboards = getGroupItems("dashboards");
const views = getGroupItems("views");
const apps = getGroupItems("apps");
const otherRoutes = getGroupItems("other_routes");
const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => {
@@ -233,6 +275,7 @@ export class HaNavigationPicker extends LitElement {
addGroup("related", related);
addGroup("dashboards", dashboards);
addGroup("views", views);
addGroup("apps", apps);
addGroup("other_routes", otherRoutes);
return items;
@@ -271,27 +314,24 @@ export class HaNavigationPicker extends LitElement {
const related = this._navigationGroups.related;
const dashboards: NavigationItem[] = [];
const views: NavigationItem[] = [];
const apps: NavigationItem[] = [];
const otherRoutes: NavigationItem[] = [];
for (const panel of panels) {
if (SYSTEM_PANELS.includes(panel.id)) continue;
// Skip app panels — they are handled by the ingress panels fetch below
if (panel.component_name === "app") continue;
const path = `/${panel.url_path}`;
const panelTitle = getPanelTitle(this.hass, panel);
const primary = panelTitle || path;
const resolved = computeNavigationPathInfo(this.hass!, path);
const isDashboardPanel =
panel.component_name === "lovelace" ||
PANEL_DASHBOARDS.includes(panel.id);
const panelItem: NavigationItem = {
id: path,
primary,
secondary: panelTitle ? path : undefined,
icon: getPanelIcon(panel) || "mdi:view-dashboard",
sorting_label: [
primary.startsWith("/") ? `zzz${primary}` : primary,
path,
]
.filter(Boolean)
.join("_"),
primary: resolved.label,
secondary: resolved.label !== path ? path : undefined,
icon: resolved.icon || "mdi:view-dashboard",
sorting_label: createSortingLabel(resolved.label, path),
group: isDashboardPanel ? "dashboards" : "other_routes",
};
@@ -307,26 +347,69 @@ export class HaNavigationPicker extends LitElement {
config.views.forEach((view, index) => {
const viewPath = `/${panel.url_path}/${view.path ?? index}`;
const viewPrimary =
view.title ?? (view.path ? titleCase(view.path) : `${index}`);
const viewInfo = computeNavigationPathInfo(
this.hass!,
viewPath,
config
);
views.push({
id: viewPath,
secondary: viewPath,
icon: view.icon ?? "mdi:view-compact",
primary: viewPrimary,
sorting_label: [
viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary,
viewPath,
].join("_"),
icon: viewInfo.icon || "mdi:view-compact",
primary: viewInfo.label,
sorting_label: createSortingLabel(viewInfo.label, viewPath),
group: "views",
});
});
}
// Fetch all ingress add-on panels
if (isComponentLoaded(this.hass!.config, "hassio")) {
try {
const ingressPanels = await subscribeOneCollection(
getIngressPanelInfoCollection(this.hass!.connection)
);
for (const slug of Object.keys(ingressPanels)) {
const path = `/app/${slug}`;
const resolved = computeNavigationPathInfo(
this.hass!,
path,
undefined,
ingressPanels
);
apps.push({
id: path,
primary: resolved.label,
secondary: path,
icon: resolved.icon,
icon_path: resolved.iconPath,
sorting_label: createSortingLabel(resolved.label, path),
group: "apps",
});
}
} catch (_err) {
// Supervisor may not be available, silently ignore
}
}
for (const [subPath, route] of Object.entries(CONFIG_SUB_ROUTES)) {
const path = `/config/${subPath}`;
const label = this.hass!.localize(route.translationKey) || subPath;
otherRoutes.push({
id: path,
primary: label,
secondary: path,
icon_path: route.iconPath,
sorting_label: createSortingLabel(label, path),
group: "other_routes",
});
}
this._navigationGroups = {
related,
dashboards,
views,
apps,
other_routes: otherRoutes,
};
@@ -334,6 +417,7 @@ export class HaNavigationPicker extends LitElement {
...related,
...dashboards,
...views,
...apps,
...otherRoutes,
];
@@ -356,6 +440,7 @@ export class HaNavigationPicker extends LitElement {
...relatedItems,
...this._navigationGroups.dashboards,
...this._navigationGroups.views,
...this._navigationGroups.apps,
...this._navigationGroups.other_routes,
];
};
@@ -399,28 +484,20 @@ export class HaNavigationPicker extends LitElement {
relatedAreaIds.add(context.area_id);
}
const createSortingLabel = (
prefix: string,
primary: string,
path: string
) =>
[prefix, primary.startsWith("/") ? `zzz${primary}` : primary, path]
.filter(Boolean)
.join("_");
const relatedItems: NavigationItem[] = [];
for (const deviceId of relatedDeviceIds) {
const device = this.hass.devices[deviceId];
const primary = device?.name_by_user ?? device?.name ?? deviceId;
const path = `/config/devices/device/${deviceId}`;
const resolved = computeNavigationPathInfo(this.hass, path);
relatedItems.push({
id: path,
primary,
primary: resolved.label,
secondary: path,
icon_path: mdiDevices,
icon: resolved.icon,
icon_path: resolved.iconPath,
sorting_label: createSortingLabel(
RELATED_SORT_PREFIX.device,
primary,
resolved.label,
path
),
group: "related",
@@ -431,20 +508,18 @@ export class HaNavigationPicker extends LitElement {
}
for (const areaId of relatedAreaIds) {
const area = this.hass.areas[areaId];
const primary = area?.name ?? areaId;
// Area dashboard view
const viewPath = `/home/${computeAreaPath(areaId)}`;
const resolvedArea = computeNavigationPathInfo(this.hass, viewPath);
relatedItems.push({
id: viewPath,
primary,
primary: resolvedArea.label,
secondary: viewPath,
icon: area?.icon ?? undefined,
icon_path: area?.icon ? undefined : mdiTextureBox,
icon: resolvedArea.icon,
icon_path: resolvedArea.icon ? undefined : resolvedArea.iconPath,
sorting_label: createSortingLabel(
RELATED_SORT_PREFIX.area_view,
primary,
resolvedArea.label,
viewPath
),
group: "related",
@@ -456,14 +531,14 @@ export class HaNavigationPicker extends LitElement {
id: path,
primary: this.hass.localize(
"ui.components.navigation-picker.area_settings",
{ area: primary }
{ area: resolvedArea.label }
),
secondary: path,
icon: area?.icon ?? undefined,
icon_path: area?.icon ? undefined : mdiTextureBox,
icon: resolvedArea.icon,
icon_path: resolvedArea.icon ? undefined : resolvedArea.iconPath,
sorting_label: createSortingLabel(
RELATED_SORT_PREFIX.area,
primary,
resolvedArea.label,
path
),
group: "related",

View File

@@ -1,4 +1,4 @@
import { mdiInformationOutline, mdiStar } from "@mdi/js";
import { mdiStar } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -53,65 +53,42 @@ export class HaNetwork extends LitElement {
}
const configured_adapters = this.networkConfig.configured_adapters || [];
return html`
<ha-settings-row>
<span slot="prefix">
<ha-checkbox
id="auto_configure"
@change=${this._handleAutoConfigureCheckboxClick}
.checked=${!configured_adapters.length}
name="auto_configure"
>
</ha-checkbox>
</span>
<span slot="heading" data-for="auto_configure">
${this.hass.localize(
"ui.panel.config.network.adapter.auto_configure"
)}
</span>
<span slot="description" data-for="auto_configure">
<ha-checkbox
@change=${this._handleAutoConfigureCheckboxClick}
.checked=${!configured_adapters.length}
.hint=${!configured_adapters.length
? this.hass.localize(
"ui.panel.config.network.adapter.auto_configure_manual_hint"
)
: ""}
>
${this.hass.localize("ui.panel.config.network.adapter.auto_configure")}
<div class="description">
${this.hass.localize("ui.panel.config.network.adapter.detected")}:
${format_auto_detected_interfaces(this.networkConfig.adapters)}
${!configured_adapters.length
? html`<div class="info-text">
<ha-svg-icon
.path=${mdiInformationOutline}
class="info-icon"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.network.adapter.auto_configure_manual_hint"
)}
</div>`
: nothing}
</span>
</ha-settings-row>
</div>
</ha-checkbox>
${configured_adapters.length || this._expanded
? this.networkConfig.adapters.map(
(adapter) =>
html`<ha-settings-row>
<span slot="prefix">
<ha-checkbox
id=${adapter.name}
@change=${this._handleAdapterCheckboxClick}
.checked=${configured_adapters.includes(adapter.name)}
.adapter=${adapter.name}
name=${adapter.name}
>
</ha-checkbox>
</span>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.network.adapter.adapter"
)}:
${adapter.name}
${adapter.default
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
(${this.hass.localize("ui.common.default")})`
: nothing}
</span>
<span slot="description">
html`<ha-checkbox
id=${adapter.name}
@change=${this._handleAdapterCheckboxClick}
.checked=${configured_adapters.includes(adapter.name)}
.adapter=${adapter.name}
>
${this.hass.localize(
"ui.panel.config.network.adapter.adapter"
)}:
${adapter.name}
${adapter.default
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
(${this.hass.localize("ui.common.default")})`
: nothing}
<div class="description">
${format_addresses([...adapter.ipv4, ...adapter.ipv6])}
</span>
</ha-settings-row>`
</div>
</ha-checkbox>`
)
: nothing}
`;
@@ -145,7 +122,7 @@ export class HaNetwork extends LitElement {
private _handleAdapterCheckboxClick(ev: Event) {
const checkbox = ev.currentTarget as HaCheckbox;
const adapter_name = (checkbox as any).name;
const adapter_name = checkbox.id;
if (this.networkConfig === undefined) {
return;
}
@@ -172,31 +149,20 @@ export class HaNetwork extends LitElement {
color: var(--error-color);
}
ha-settings-row {
padding: 0;
--settings-row-content-display: contents;
--settings-row-prefix-display: contents;
ha-checkbox:not(:last-child) {
margin-bottom: var(--ha-space-3);
}
span[slot="heading"],
span[slot="description"] {
cursor: pointer;
ha-svg-icon {
--mdc-icon-size: 12px;
margin-bottom: 4px;
}
.info-text {
display: flex;
align-items: center;
margin-top: 8px;
.description {
font-size: var(--ha-font-size-s);
margin-top: var(--ha-space-1);
color: var(--secondary-text-color);
}
.info-icon {
width: 18px;
height: 18px;
color: var(--info-color, var(--primary-color));
margin-right: 8px;
flex-shrink: 0;
}
`,
];
}

View File

@@ -11,7 +11,7 @@ import type { HaPickerField } from "./ha-picker-field";
import "./ha-svg-icon";
export interface HaSelectOption {
value: string;
value: string | number;
label?: string;
secondary?: string;
iconPath?: string;
@@ -34,13 +34,16 @@ export type HaSelectSelectEvent<
export class HaSelect extends LitElement {
@property({ type: Boolean }) public clearable = false;
@property({ attribute: false }) public options?: HaSelectOption[] | string[];
@property({ attribute: false }) public options?:
| HaSelectOption[]
| string[]
| number[];
@property() public label?: string;
@property() public helper?: string;
@property() public value?: string;
@property() public value?: string | number;
@property({ type: Boolean }) public required = false;
@@ -52,25 +55,30 @@ export class HaSelect extends LitElement {
private _getValueLabel = memoizeOne(
(
options: HaSelectOption[] | string[] | undefined,
value: string | undefined
options: HaSelectOption[] | string[] | number[] | undefined,
value: string | number | undefined
) => {
if (!options || !value) {
return value;
// just in case value is a number, convert it to string to avoid falsy value
const valueStr = String(value);
if (!options || !valueStr) {
return valueStr;
}
for (const option of options) {
const simpleOption = ["string", "number"].includes(typeof option);
if (
(typeof option === "string" && option === value) ||
(typeof option !== "string" && option.value === value)
(simpleOption && option === valueStr) ||
(!simpleOption &&
String((option as HaSelectOption).value) === valueStr)
) {
return typeof option === "string"
return simpleOption
? option
: option.label || option.value;
: (option as HaSelectOption).label ||
(option as HaSelectOption).value;
}
}
return value;
return valueStr;
}
);
@@ -88,15 +96,14 @@ export class HaSelect extends LitElement {
>
${this._renderField()}
${this.options
? this.options.map(
(option) => html`
? this.options.map((option) => {
const simpleOption = ["string", "number"].includes(typeof option);
return html`
<ha-dropdown-item
.value=${typeof option === "string" ? option : option.value}
.disabled=${typeof option === "string"
? false
: (option.disabled ?? false)}
.value=${simpleOption ? option : option.value}
.disabled=${simpleOption ? false : (option.disabled ?? false)}
.selected=${this.value ===
(typeof option === "string" ? option : option.value)}
(simpleOption ? option : option.value)}
>
${option.iconPath
? html`<ha-svg-icon
@@ -105,16 +112,14 @@ export class HaSelect extends LitElement {
></ha-svg-icon>`
: nothing}
<div class="content">
${typeof option === "string"
? option
: option.label || option.value}
${simpleOption ? option : option.label || option.value}
${option.secondary
? html`<div class="secondary">${option.secondary}</div>`
: nothing}
</div>
</ha-dropdown-item>
`
)
`;
})
: html`<slot></slot>`}
</ha-dropdown>
${this._renderHelper()}
@@ -139,7 +144,7 @@ export class HaSelect extends LitElement {
.hideClearIcon=${!this.clearable ||
this.required ||
this.disabled ||
!this.value}
!String(this.value)}
>
</ha-picker-field>
`;
@@ -153,7 +158,7 @@ export class HaSelect extends LitElement {
: nothing;
}
private _handleSelect(ev: CustomEvent<{ item: { value: string } }>) {
private _handleSelect(ev: CustomEvent<{ item: { value: string | number } }>) {
ev.stopPropagation();
const value = ev.detail.item.value;
if (value === this.value) {
@@ -216,6 +221,6 @@ declare global {
}
interface HASSDomEvents {
selected: { value: string | undefined };
selected: { value: string | number | undefined };
}
}

View File

@@ -136,14 +136,14 @@ export class HaSelectSelector extends LitElement {
${this.label}
${options.map(
(item: SelectOption) => html`
<ha-formfield .label=${item.label}>
<ha-checkbox
.checked=${value.includes(item.value)}
.value=${item.value}
.disabled=${item.disabled || this.disabled}
@change=${this._checkboxChanged}
></ha-checkbox>
</ha-formfield>
<ha-checkbox
.checked=${value.includes(item.value)}
.value=${item.value}
.disabled=${item.disabled || this.disabled}
@change=${this._checkboxChanged}
>
${item.label}
</ha-checkbox>
`
)}
</div>
@@ -231,7 +231,9 @@ export class HaSelectSelector extends LitElement {
return html`
<ha-select
.label=${this.label ?? ""}
.value=${typeof this.value === "string" ? this.value : ""}
.value=${["string", "number"].includes(typeof this.value)
? (this.value as string | number)
: ""}
.helper=${this.helper ?? ""}
.disabled=${this.disabled}
.required=${this.required}
@@ -256,7 +258,7 @@ export class HaSelectSelector extends LitElement {
selector.select?.options?.map((option) =>
typeof option === "object"
? (option as SelectOption)
: ({ value: option, label: option } as SelectOption)
: ({ value: String(option), label: option } as SelectOption)
) || []
);
@@ -300,7 +302,7 @@ export class HaSelectSelector extends LitElement {
}
private _valueChanged(ev) {
const value = ev.detail?.value || ev.target.value;
const value = ev.detail?.value ?? ev.target.value;
if (this.disabled || value === undefined || value === (this.value ?? "")) {
return;
}
@@ -383,6 +385,12 @@ export class HaSelectSelector extends LitElement {
ha-formfield {
display: block;
}
ha-checkbox {
display: flex;
min-height: 40px;
justify-content: center;
}
ha-dropdown-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}

View File

@@ -0,0 +1,197 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import type { SerialSelector } from "../../data/selector";
import { listSerialPorts, type SerialPort } from "../../data/usb";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-icon-button";
import "../input/ha-input";
const MANUAL_ENTRY_ID = "__manual_entry__";
@customElement("ha-selector-serial")
export class HaSerialSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: SerialSelector;
@property() public value?: string;
@property() public label?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private _serialPorts?: SerialPort[];
@state() private _manualEntry = false;
@query("ha-input") private _input?: HTMLElement;
protected firstUpdated(): void {
if (
this.hass &&
this.hass.user?.is_admin &&
isComponentLoaded(this.hass.config, "usb")
) {
this._loadSerialPorts();
}
}
private async _loadSerialPorts(): Promise<void> {
try {
this._serialPorts = await listSerialPorts(this.hass);
} catch (err: unknown) {
// eslint-disable-next-line no-console
console.error(err);
this._serialPorts = undefined;
}
}
private _humanReadablePort(port: SerialPort): string {
const parts: string[] = [port.device];
if (port.manufacturer) {
parts.push(port.manufacturer);
}
if (port.description) {
parts.push(port.description);
}
return parts.join(" - ");
}
private _getPickerItems = (): (PickerComboBoxItem | string)[] | undefined =>
this._serialPorts
? this._getItems(this._serialPorts, this.hass.localize)
: undefined;
private _getItems = memoizeOne(
(
ports: SerialPort[],
localize: HomeAssistant["localize"]
): (PickerComboBoxItem | string)[] => {
const items: (PickerComboBoxItem | string)[] = ports.map((port) => ({
id: port.device,
primary: this._humanReadablePort(port),
secondary: port.vid
? `${port.vid}:${port.pid}${port.serial_number ? ` - S/N: ${port.serial_number}` : ""}`
: undefined,
search_labels: {
device: port.device,
manufacturer: port.manufacturer,
description: port.description,
serial_number: port.serial_number,
},
sorting_label: port.device,
}));
items.push({
id: MANUAL_ENTRY_ID,
primary: localize("ui.components.selectors.serial.enter_manually"),
secondary: undefined,
});
return items;
}
);
protected render() {
const usbLoaded = this.hass && isComponentLoaded(this.hass.config, "usb");
if (!usbLoaded || !this._serialPorts || this._manualEntry) {
return html`
<ha-input
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.hint=${this.helper}
.disabled=${this.disabled}
.label=${this.label || ""}
.required=${this.required}
@input=${this._handleInputChange}
@change=${this._handleInputChange}
>
${this._manualEntry
? html`
<ha-icon-button
slot="end"
@click=${this._revertToDropdown}
.path=${mdiClose}
></ha-icon-button>
`
: nothing}
</ha-input>
`;
}
return html`
<ha-generic-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getPickerItems}
@value-changed=${this._handlePickerChange}
></ha-generic-picker>
`;
}
private async _handlePickerChange(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (value === MANUAL_ENTRY_ID) {
this._manualEntry = true;
fireEvent(this, "value-changed", { value: undefined });
await this.updateComplete;
// Wait for the picker popover to fully close and restore focus
// before moving focus to our input
requestAnimationFrame(() => {
this._input?.focus();
});
return;
}
fireEvent(this, "value-changed", { value: value || undefined });
}
private _handleInputChange(ev: InputEvent) {
ev.stopPropagation();
const value = (ev.target as HTMLInputElement).value;
fireEvent(this, "value-changed", {
value: value || undefined,
});
}
private _revertToDropdown() {
this._manualEntry = false;
const ports = this._serialPorts;
const firstPort = ports?.[0]?.device;
fireEvent(this, "value-changed", {
value: firstPort || undefined,
});
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-generic-picker,
ha-input {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-serial": HaSerialSelector;
}
}

View File

@@ -78,7 +78,7 @@ export class HaTextSelector extends LitElement {
return html`<ha-input
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.placeholder=${this.placeholder || this.selector.text?.placeholder || ""}
.hint=${this.helper}
.disabled=${this.disabled}
.type=${this.selector.text?.type}

View File

@@ -14,6 +14,8 @@ export class HaThemeSelector extends LitElement {
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = true;
@@ -24,6 +26,7 @@ export class HaThemeSelector extends LitElement {
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.includeDefault=${this.selector.theme?.include_default}
.disabled=${this.disabled}
.required=${this.required}

View File

@@ -4,8 +4,8 @@ import { fireEvent } from "../../common/dom/fire_event";
import type { ActionConfig } from "../../data/lovelace/config/action";
import type { UiActionSelector } from "../../data/selector";
import "../../panels/lovelace/components/hui-action-editor";
import type { HomeAssistant } from "../../types";
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
import type { HomeAssistant } from "../../types";
@customElement("ha-selector-ui_action")
export class HaSelectorUiAction extends LitElement {
@@ -21,10 +21,13 @@ export class HaSelectorUiAction extends LitElement {
@property() public helper?: string;
@property({ type: Boolean }) public required?: boolean;
protected render() {
return html`
<hui-action-editor
.label=${this.label}
.required=${this.required}
.hass=${this.hass}
.config=${this.value}
.context=${this.context}

View File

@@ -45,6 +45,7 @@ const LOAD_ELEMENTS = {
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
selector: () => import("./ha-selector-selector"),
serial: () => import("./ha-selector-serial"),
state: () => import("./ha-selector-state"),
backup_location: () => import("./ha-selector-backup-location"),
stt: () => import("./ha-selector-stt"),

View File

@@ -679,13 +679,16 @@ export class HaServiceControl extends LitElement {
: html`<ha-checkbox
.key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) ||
(this._value?.data &&
(!!this._value?.data &&
this._value.data[dataField.key] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
<span
slot="heading"
class=${showOptional ? "clickable" : ""}
@click=${showOptional ? this._toggleCheckbox : undefined}
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name`,
descriptionPlaceholders
@@ -693,7 +696,10 @@ export class HaServiceControl extends LitElement {
dataField.name ||
dataField.key}</span
>
<span slot="description"
<span
slot="description"
class=${showOptional ? "clickable" : ""}
@click=${showOptional ? this._toggleCheckbox : undefined}
><ha-markdown
breaks
allow-svg
@@ -738,6 +744,13 @@ export class HaServiceControl extends LitElement {
);
};
private _toggleCheckbox(ev: Event) {
const checkbox = (
ev.currentTarget as HTMLElement
)?.parentElement?.querySelector("ha-checkbox");
checkbox?.click();
}
private _checkboxChanged(ev) {
const checked = ev.currentTarget.checked;
const key = ev.currentTarget.key;
@@ -995,10 +1008,8 @@ export class HaServiceControl extends LitElement {
.checkbox-spacer {
width: 32px;
}
ha-checkbox {
margin-left: -16px;
margin-inline-start: -16px;
margin-inline-end: initial;
.clickable {
cursor: pointer;
}
.help-icon {
color: var(--secondary-text-color);

View File

@@ -8,13 +8,7 @@ import {
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -47,9 +41,9 @@ import "./ha-icon";
import "./ha-icon-button";
import "./ha-md-list";
import "./ha-md-list-item";
import type { HaMdListItem } from "./ha-md-list-item";
import "./ha-spinner";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./user/ha-user-badge";
const SORT_VALUE_URL_PATHS = {
@@ -185,18 +179,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@state() private _hiddenPanels?: string[];
private _mouseLeaveTimeout?: number;
private _touchendTimeout?: number;
private _tooltipHideTimeout?: number;
private _recentKeydownActiveUntil = 0;
private _unsubPersistentNotifications: UnsubscribeFunc | undefined;
@query(".tooltip") private _tooltip!: HTMLDivElement;
@query(".before-spacer") private _scrollableList?: HTMLDivElement;
protected get scrollableElement(): HTMLElement | null {
@@ -237,14 +221,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
public disconnectedCallback() {
super.disconnectedCallback();
// clear timeouts
clearTimeout(this._mouseLeaveTimeout);
clearTimeout(this._tooltipHideTimeout);
clearTimeout(this._touchendTimeout);
// set undefined values
this._mouseLeaveTimeout = undefined;
this._tooltipHideTimeout = undefined;
this._touchendTimeout = undefined;
}
protected render() {
@@ -257,8 +233,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
// prettier-ignore
return html`
${this._renderHeader()}
${this._renderAllPanels(selectedPanel)}
<div class="tooltip"></div>`;
${this._renderAllPanels(selectedPanel)}`;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
@@ -382,11 +357,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
"ha-scrollbar": scrollable,
[cls]: true,
})}
@focusin=${this._listboxFocusIn}
@focusout=${this._listboxFocusOut}
@touchend=${this._listboxTouchend}
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>${content}</ha-md-list
>`;
@@ -462,15 +432,17 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<ha-md-list-item
.href=${`/${urlPath}`}
type="link"
id="sidebar-panel-${urlPath}"
class=${classMap({ selected: isSelected })}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-md-list-item>
${!this.alwaysExpand && title
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
: nothing}
`;
}
@@ -489,8 +461,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
class="configuration ${classMap({ selected: isSelected })}"
type="button"
href="/config"
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
id="sidebar-config"
>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
${this._updatesCount > 0 || this._issuesCount > 0
@@ -511,6 +482,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
`
: nothing}
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-config",
this.hass.localize("panel.config")
)
: nothing}
`;
}
@@ -523,9 +500,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<ha-md-list-item
class="notifications"
@click=${this._handleShowNotificationDrawer}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
type="button"
id="sidebar-notifications"
>
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
${notificationCount > 0
@@ -540,6 +516,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
? html`<span class="badge" slot="end">${notificationCount}</span>`
: nothing}
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-notifications",
this.hass.localize("ui.notification_drawer.title")
)
: nothing}
`;
}
@@ -551,13 +533,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<ha-md-list-item
href="/profile"
type="link"
id="sidebar-profile"
class=${classMap({
user: true,
selected: isSelected,
rtl: isRTL,
})}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
<ha-user-badge
slot="start"
@@ -568,6 +549,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-md-list-item>
${!this.alwaysExpand && this.hass.user
? this._renderToolTip("sidebar-profile", this.hass.user.name)
: nothing}
`;
}
@@ -579,17 +563,33 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<ha-md-list-item
@click=${this._handleExternalAppConfiguration}
type="button"
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
id="sidebar-external-config"
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-external-config",
this.hass.localize("ui.sidebar.external_app_configuration")
)
: nothing}
`;
}
private _renderToolTip(id: string, text: string) {
return html`<ha-tooltip
for=${id}
show-delay="0"
hide-delay="0"
placement="right"
>
${text}
</ha-tooltip>`;
}
private _handleExternalAppConfiguration(ev: Event) {
ev.preventDefault();
this.hass.auth.external!.fireMessage({
@@ -605,98 +605,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
showEditSidebarDialog(this);
}
private _itemMouseEnter(ev: MouseEvent) {
// On keypresses on the listbox, we're going to ignore mouse enter events
// for 100ms so that we ignore it when pressing down arrow scrolls the
// sidebar causing the mouse to hover a new icon
if (new Date().getTime() < this._recentKeydownActiveUntil) {
return;
}
if (this._mouseLeaveTimeout) {
clearTimeout(this._mouseLeaveTimeout);
this._mouseLeaveTimeout = undefined;
}
this._showTooltip(ev.currentTarget as HaMdListItem);
}
private _itemMouseLeave() {
if (this._mouseLeaveTimeout) {
clearTimeout(this._mouseLeaveTimeout);
}
this._mouseLeaveTimeout = window.setTimeout(() => {
this._hideTooltip();
}, 500);
}
private _listboxFocusIn(ev) {
if (ev.target.localName !== "ha-md-list-item") {
return;
}
this._showTooltip(ev.target);
}
private _listboxFocusOut() {
this._hideTooltip();
}
private _listboxTouchend() {
clearTimeout(this._touchendTimeout);
this._touchendTimeout = window.setTimeout(() => {
// Allow 1 second for users to read the tooltip on touch devices
this._hideTooltip();
}, 1000);
}
@eventOptions({
passive: true,
})
private _listboxScroll() {
// On keypresses on the listbox, we're going to ignore scroll events
// for 100ms so that if pressing down arrow scrolls the sidebar, the tooltip
// will not be hidden.
if (new Date().getTime() < this._recentKeydownActiveUntil) {
return;
}
this._hideTooltip();
}
private _listboxKeydown() {
this._recentKeydownActiveUntil = new Date().getTime() + 100;
}
private _showTooltip(item: HaMdListItem) {
if (this._tooltipHideTimeout) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = undefined;
}
const itemText = item.querySelector(".item-text") as HTMLElement | null;
if (this.hasAttribute("expanded") && itemText) {
const isTruncated = itemText.scrollWidth > itemText.clientWidth;
if (!isTruncated) {
this._hideTooltip();
return;
}
}
const tooltip = this._tooltip;
const itemRect = item.getBoundingClientRect();
tooltip.innerText = itemText?.innerText ?? "";
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${itemRect.top + itemRect.height / 2 - tooltip.offsetHeight / 2}px`;
tooltip.style.left = `calc(${itemRect.right + 8}px)`;
}
private _hideTooltip() {
// Delay it a little in case other events are pending processing.
if (!this._tooltipHideTimeout) {
this._tooltipHideTimeout = window.setTimeout(() => {
this._tooltipHideTimeout = undefined;
this._tooltip.style.display = "none";
}, 10);
}
}
private _handleShowNotificationDrawer() {
fireEvent(this, "hass-show-notifications");
}
@@ -957,20 +865,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
pointer-events: none;
}
.tooltip {
display: none;
position: absolute;
opacity: 0.9;
border-radius: var(--ha-border-radius-sm);
max-width: calc(var(--ha-space-20) * 3);
white-space: normal;
overflow-wrap: break-word;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);
font-weight: var(--ha-font-weight-medium);
}
.menu ha-icon-button {
-webkit-transform: scaleX(var(--scale-direction));
transform: scaleX(var(--scale-direction));

View File

@@ -56,7 +56,7 @@ export class HaSlider extends Slider {
--ha-tooltip-border-radius,
var(--ha-border-radius-sm)
);
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 8px);
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 0px);
--wa-tooltip-border-width: 0px;
--wa-z-index-tooltip: 1000;
min-width: 100px;

View File

@@ -1,49 +1,220 @@
import { SwitchBase } from "@material/mwc-switch/deprecated/mwc-switch-base";
import { styles } from "@material/mwc-switch/deprecated/mwc-switch.css";
import { css } from "lit";
import Switch from "@home-assistant/webawesome/dist/components/switch/switch";
import { css, type CSSResultGroup, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { forwardHaptic } from "../data/haptics";
/**
* Home Assistant switch component
*
* @element ha-switch
* @extends {Switch}
*
* @summary
* A toggle switch component supporting Home Assistant theming, based on the webawesome switch.
* Represents two states: on and off.
*
* @cssprop --ha-switch-size - The size of the switch track height. Defaults to `24px`.
* @cssprop --ha-switch-thumb-size - The size of the thumb. Defaults to `18px`.
* @cssprop --ha-switch-width - The width of the switch track. Defaults to `48px`.
* @cssprop --ha-switch-background-color - Background color of the unchecked track.
* @cssprop --ha-switch-thumb-background-color - Background color of the unchecked thumb.
* @cssprop --ha-switch-background-color-hover - Background color of the unchecked track on hover.
* @cssprop --ha-switch-thumb-background-color-hover - Background color of the unchecked thumb on hover.
* @cssprop --ha-switch-checked-background-color - Background color of the checked track.
* @cssprop --ha-switch-checked-thumb-background-color - Background color of the checked thumb.
* @cssprop --ha-switch-checked-background-color-hover - Background color of the checked track on hover.
* @cssprop --ha-switch-checked-thumb-background-color-hover - Background color of the checked thumb on hover.
* @cssprop --ha-switch-border-color - Border color of the unchecked track.
* @cssprop --ha-switch-thumb-border-color - Border color of the unchecked thumb.
* @cssprop --ha-switch-thumb-border-color-hover - Border color of the unchecked thumb on hover.
* @cssprop --ha-switch-checked-border-color - Border color of the checked track.
* @cssprop --ha-switch-checked-thumb-border-color - Border color of the checked thumb.
* @cssprop --ha-switch-checked-border-color-hover - Border color of the checked track on hover.
* @cssprop --ha-switch-checked-thumb-border-color-hover - Border color of the checked thumb on hover.
* @cssprop --ha-switch-thumb-box-shadow - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
* @cssprop --ha-switch-disabled-opacity - Opacity of the switch when disabled. Defaults to `0.2`.
* @cssprop --ha-switch-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
* @cssprop --ha-switch-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
*
* @attr {boolean} checked - The checked state of the switch.
* @attr {boolean} disabled - Disables the switch and prevents user interaction.
* @attr {boolean} required - Makes the switch a required field.
* @attr {boolean} haptic - Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when a save action is required).
*/
@customElement("ha-switch")
export class HaSwitch extends SwitchBase {
// Generate a haptic vibration.
// Only set to true if the new value of the switch is applied right away when toggling.
// Do not add haptic when a user is required to press save.
export class HaSwitch extends Switch {
/**
* Enables haptic vibration on toggle.
* Only set to true if the new value of the switch is applied right away when toggling.
* Do not add haptic when a user is required to press save.
*/
@property({ type: Boolean }) public haptic = false;
protected firstUpdated() {
super.firstUpdated();
this.addEventListener("change", () => {
public updated(changedProperties: PropertyValues<typeof this>) {
super.updated(changedProperties);
if (changedProperties.has("haptic")) {
if (this.haptic) {
forwardHaptic(this, "light");
this.addEventListener("change", this._forwardHaptic);
} else {
this.removeEventListener("change", this._forwardHaptic);
}
});
}
}
static override styles = [
styles,
css`
:host {
--mdc-theme-secondary: var(--switch-checked-color);
}
.mdc-switch.mdc-switch--checked .mdc-switch__thumb {
background-color: var(--switch-checked-button-color);
border-color: var(--switch-checked-button-color);
}
.mdc-switch.mdc-switch--checked .mdc-switch__track {
background-color: var(--switch-checked-track-color);
border-color: var(--switch-checked-track-color);
}
.mdc-switch:not(.mdc-switch--checked) .mdc-switch__thumb {
background-color: var(--switch-unchecked-button-color);
border-color: var(--switch-unchecked-button-color);
}
.mdc-switch:not(.mdc-switch--checked) .mdc-switch__track {
background-color: var(--switch-unchecked-track-color);
border-color: var(--switch-unchecked-track-color);
}
`,
];
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("change", this._forwardHaptic);
}
private _forwardHaptic = () => {
forwardHaptic(this, "light");
};
static get styles(): CSSResultGroup {
return [
Switch.styles,
css`
:host {
--wa-form-control-toggle-size: var(--ha-switch-size, 24px);
--wa-form-control-required-content: var(
--ha-switch-required-marker,
var(--ha-input-required-marker, "*")
);
--wa-form-control-required-content-offset: var(
--ha-switch-required-marker-offset,
0.1rem
);
--thumb-size: var(--ha-switch-thumb-size, 18px);
--width: var(--ha-switch-width, 48px);
}
label {
height: max(var(--thumb-size), var(--wa-form-control-toggle-size));
}
.switch {
background-color: var(
--ha-switch-background-color,
var(--ha-color-fill-disabled-quiet-resting)
);
border-color: var(
--ha-switch-border-color,
var(--ha-color-border-neutral-normal)
);
}
label:not(.disabled):hover .switch,
label:not(.disabled) .input:focus-visible ~ .switch {
background-color: var(
--ha-switch-background-color-hover,
var(
--ha-switch-background-color,
var(--ha-color-fill-disabled-quiet-hover)
)
);
}
.checked .switch {
background-color: var(
--ha-switch-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
border-color: var(
--ha-switch-checked-border-color,
var(--ha-color-border-primary-loud)
);
}
label:not(.disabled).checked:hover .switch,
label:not(.disabled).checked .input:focus-visible ~ .switch {
background-color: var(
--ha-switch-checked-background-color-hover,
var(
--ha-switch-checked-background-color,
var(--ha-color-fill-primary-normal-hover)
)
);
border-color: var(
--ha-switch-checked-border-color-hover,
var(
--ha-switch-checked-border-color,
var(--ha-color-border-primary-loud)
)
);
}
.switch .thumb {
background-color: var(
--ha-switch-thumb-background-color,
var(--ha-color-on-neutral-normal)
);
border-color: var(
--ha-switch-thumb-border-color,
var(--ha-color-on-neutral-normal)
);
border-style: var(--wa-form-control-border-style);
border-width: var(--wa-form-control-border-width);
box-shadow: var(--ha-switch-thumb-box-shadow, var(--ha-box-shadow-s));
}
label:not(.disabled):hover .switch .thumb,
label:not(.disabled) .input:focus-visible ~ .switch .thumb {
background-color: var(
--ha-switch-thumb-background-color-hover,
var(
--ha-switch-thumb-background-color,
var(--ha-color-on-neutral-normal)
)
);
border-color: var(
--ha-switch-thumb-border-color-hover,
var(
--ha-switch-thumb-border-color,
var(--ha-color-on-neutral-normal)
)
);
}
.checked .switch .thumb {
background-color: var(
--ha-switch-checked-thumb-background-color,
var(--ha-color-on-primary-normal)
);
border-color: var(
--ha-switch-checked-thumb-border-color,
var(--ha-color-on-primary-normal)
);
}
label:not(.disabled).checked:hover .switch .thumb,
label:not(.disabled).checked .input:focus-visible ~ .switch .thumb {
background-color: var(
--ha-switch-checked-thumb-background-color-hover,
var(
--ha-switch-checked-thumb-background-color,
var(--ha-color-on-primary-normal)
)
);
border-color: var(
--ha-switch-checked-thumb-border-color-hover,
var(
--ha-switch-checked-thumb-border-color,
var(--ha-color-on-primary-normal)
)
);
}
label.disabled {
opacity: var(--ha-switch-disabled-opacity, 0.3);
cursor: not-allowed;
}
/* Focus */
label:not(.disabled) .input:focus-visible ~ .switch .thumb {
outline: none;
outline-offset: none;
}
label:not(.disabled) .input:focus-visible ~ .switch {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
`,
];
}
}
declare global {

View File

@@ -14,6 +14,8 @@ export class HaThemePicker extends LitElement {
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@@ -49,6 +51,7 @@ export class HaThemePicker extends LitElement {
.label=${this.label ||
this.hass!.localize("ui.components.theme-picker.theme")}
.value=${this.value}
.helper=${this.helper}
.required=${this.required}
.disabled=${this.disabled}
@selected=${this._changed}

View File

@@ -8,6 +8,7 @@ import {
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { nextRender } from "../common/util/render-status";
@@ -28,6 +29,9 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -186,6 +190,9 @@ export class HaToast extends LitElement {
active: this._active,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
@@ -205,7 +212,8 @@ export class HaToast extends LitElement {
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4)
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--ha-toast-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
@@ -232,15 +240,15 @@ export class HaToast extends LitElement {
transform var(--ha-animation-duration-fast, 150ms) ease;
}
.toast:not(.active) {
display: none;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
.toast:not(.active) {
display: none;
}
.message {
flex: 1;
min-width: 0;

View File

@@ -1,12 +1,12 @@
import Tooltip from "@home-assistant/webawesome/dist/components/tooltip/tooltip";
import { css } from "lit";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-tooltip")
export class HaTooltip extends Tooltip {
/** The amount of time to wait before showing the tooltip when the user mouses in. */
@property({ attribute: "show-delay", type: Number }) showDelay = 150;
@property({ attribute: "show-delay", type: Number }) showDelay = 350;
/** The amount of time to wait before hiding the tooltip when the user mouses out.. */
@property({ attribute: "hide-delay", type: Number }) hideDelay = 150;
@@ -18,7 +18,7 @@ export class HaTooltip extends Tooltip {
:host {
--wa-tooltip-background-color: var(
--ha-tooltip-background-color,
var(--secondary-background-color)
var(--ha-color-surface-default)
);
--wa-tooltip-content-color: var(
--ha-tooltip-text-color,
@@ -30,11 +30,11 @@ export class HaTooltip extends Tooltip {
);
--wa-tooltip-font-size: var(
--ha-tooltip-font-size,
var(--ha-font-size-s)
var(--ha-font-size-m)
);
--wa-tooltip-font-weight: var(
--ha-tooltip-font-weight,
var(--ha-font-weight-normal)
var(--ha-font-weight-medium)
);
--wa-tooltip-line-height: var(
--ha-tooltip-line-height,
@@ -43,12 +43,20 @@ export class HaTooltip extends Tooltip {
--wa-tooltip-padding: var(--ha-tooltip-padding, var(--ha-space-2));
--wa-tooltip-border-radius: var(
--ha-tooltip-border-radius,
var(--ha-border-radius-sm)
var(--ha-border-radius-md)
);
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 8px);
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 0px);
--wa-tooltip-border-width: 0px;
--wa-z-index-tooltip: 1000;
}
.tooltip::part(popup) {
animation-duration: var(--ha-tooltip-animation-duration, 0);
}
.body {
box-shadow: var(--ha-tooltip-box-shadow, var(--ha-box-shadow-m));
}
`,
];
}

View File

@@ -1,6 +1,6 @@
import { consume, type ContextType } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import { html, type PropertyValues } from "lit";
import { css, html, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { internationalizationContext } from "../../data/context";
import { HaInput } from "./ha-input";
@@ -42,6 +42,15 @@ export class HaInputSearch extends HaInput {
protected renderStartDefault() {
return html`<ha-svg-icon slot="start" .path=${mdiMagnify}></ha-svg-icon>`;
}
static styles = [
...HaInput.styles,
css`
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 40px;
}
`,
];
}
declare global {

View File

@@ -0,0 +1,262 @@
import {
mdiDevices,
mdiHammer,
mdiLink,
mdiPalette,
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiShape,
mdiTextureBox,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { computeDeviceName } from "../common/entity/compute_device_name";
import {
getIngressPanelInfoCollection,
type IngressPanelInfoMap,
} from "./hassio/ingress";
import { getLovelaceCollection } from "./lovelace";
import type { LovelaceRawConfig } from "./lovelace/config/types";
import { computeViewIcon, computeViewTitle } from "./lovelace/config/view";
import {
APP_PANEL,
getPanelIcon,
getPanelIconPath,
getPanelTitleFromUrlPath,
} from "./panel";
import type { LocalizeKeys } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
export interface NavigationPathInfo {
label: string;
icon?: string;
iconPath: string;
}
export const DEFAULT_NAVIGATION_PATH_INFO: NavigationPathInfo = {
label: "",
iconPath: mdiLink,
};
const AREA_VIEW_PREFIX = "areas-";
export const CONFIG_SUB_ROUTES: Record<
string,
{ translationKey: LocalizeKeys; iconPath: string }
> = {
automation: {
translationKey: "ui.components.navigation-picker.route.automations",
iconPath: mdiRobot,
},
scene: {
translationKey: "ui.components.navigation-picker.route.scenes",
iconPath: mdiPalette,
},
script: {
translationKey: "ui.components.navigation-picker.route.scripts",
iconPath: mdiScriptText,
},
"developer-tools": {
translationKey: "ui.components.navigation-picker.route.developer_tools",
iconPath: mdiHammer,
},
integrations: {
translationKey: "ui.components.navigation-picker.route.integrations",
iconPath: mdiPuzzle,
},
devices: {
translationKey: "ui.components.navigation-picker.route.devices",
iconPath: mdiDevices,
},
entities: {
translationKey: "ui.components.navigation-picker.route.entities",
iconPath: mdiShape,
},
};
/**
* Resolve a navigation path to a display label and icon.
* Works synchronously for panels, areas, and devices.
* For lovelace views, pass the dashboard config to resolve view title/icon.
*/
export const computeNavigationPathInfo = (
hass: HomeAssistant,
path: string,
lovelaceConfig?: LovelaceRawConfig,
ingressPanels?: IngressPanelInfoMap
): NavigationPathInfo => {
const segments = path.replace(/^\//, "").split(/[/?]/);
const panelUrlPath = segments[0];
const subPath = segments[1];
// /config/areas/area/{areaId}
if (
panelUrlPath === "config" &&
segments[1] === "areas" &&
segments[2] === "area" &&
segments[3]
) {
return computeAreaNavigationPathInfo(hass, segments[3]);
}
// /config/devices/device/{deviceId}
if (
panelUrlPath === "config" &&
segments[1] === "devices" &&
segments[2] === "device" &&
segments[3]
) {
return computeDeviceNavigationPathInfo(hass, segments[3]);
}
// /app/<slug> (ingress addon panel)
if (panelUrlPath === APP_PANEL && subPath) {
return computeIngressNavigationPathInfo(subPath, ingressPanels);
}
// /config/{subRoute} (e.g. /config/automation, /config/integrations)
if (panelUrlPath === "config" && subPath && subPath in CONFIG_SUB_ROUTES) {
const route = CONFIG_SUB_ROUTES[subPath];
return {
label: hass.localize(route.translationKey) || subPath,
iconPath: route.iconPath,
};
}
const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined;
const panelIcon = panel ? getPanelIcon(panel) : undefined;
const panelIconPath = panel ? getPanelIconPath(panel) : undefined;
// /home/areas-{areaId} (area dashboard view)
if (subPath?.startsWith(AREA_VIEW_PREFIX)) {
const areaId = subPath.slice(AREA_VIEW_PREFIX.length);
return computeAreaNavigationPathInfo(hass, areaId);
}
const isDashboard = panel?.component_name === "lovelace";
const panelInfo: NavigationPathInfo = {
label: getPanelTitleFromUrlPath(hass, panelUrlPath) || panelUrlPath,
icon: panelIcon || (isDashboard ? "mdi:view-dashboard" : undefined),
iconPath: panelIconPath || mdiLink,
};
// Lovelace view path
if (subPath && lovelaceConfig && "views" in lovelaceConfig) {
const viewIndex = lovelaceConfig.views.findIndex(
(v, index) => (v.path ?? String(index)) === subPath
);
if (viewIndex !== -1) {
const view = lovelaceConfig.views[viewIndex];
return {
...panelInfo,
label: computeViewTitle(view, viewIndex),
icon: computeViewIcon(view),
};
}
}
return panelInfo;
};
const computeAreaNavigationPathInfo = (
hass: HomeAssistant,
areaId: string
): NavigationPathInfo => {
const area = hass.areas[areaId];
return {
label: area?.name || areaId,
icon: area?.icon || undefined,
iconPath: mdiTextureBox,
};
};
const computeDeviceNavigationPathInfo = (
hass: HomeAssistant,
deviceId: string
): NavigationPathInfo => {
const device = hass.devices[deviceId];
return {
label: (device ? computeDeviceName(device) : undefined) || deviceId,
iconPath: mdiDevices,
};
};
const computeIngressNavigationPathInfo = (
slug: string,
ingressPanels?: IngressPanelInfoMap
): NavigationPathInfo => {
const panel = ingressPanels?.[slug];
return {
label: panel?.title || slug,
icon: panel?.icon || undefined,
iconPath: mdiPuzzle,
};
};
/**
* Subscribe to navigation path info updates.
* Resolves synchronously first, then subscribes to lovelace config
* updates for view paths and ingress panel info for app paths.
*/
export const subscribeNavigationPathInfo = (
hass: HomeAssistant,
path: string,
onChange: (info: NavigationPathInfo) => void
): UnsubscribeFunc | undefined => {
const segments = path.replace(/^\//, "").split(/[/?]/);
const panelUrlPath = segments[0];
// Subscribe to ingress panels for /app/<slug> paths
if (
panelUrlPath === APP_PANEL &&
segments[1] &&
isComponentLoaded(hass.config, "hassio")
) {
try {
const collection = getIngressPanelInfoCollection(hass.connection);
// Use cached state for immediate resolution if available
const info = computeNavigationPathInfo(
hass,
path,
undefined,
collection.state
);
onChange(info);
let current = info;
return collection.subscribe((panels) => {
const newInfo = computeNavigationPathInfo(
hass,
path,
undefined,
panels
);
if (newInfo.label !== current.label || newInfo.icon !== current.icon) {
current = newInfo;
onChange(newInfo);
}
});
} catch (_err) {
// Supervisor may not be available
}
}
const info = computeNavigationPathInfo(hass, path);
onChange(info);
const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined;
if (segments[1] && panel?.component_name === "lovelace") {
let current = info;
const collection = getLovelaceCollection(hass.connection, panelUrlPath);
return collection.subscribe((config) => {
const newInfo = computeNavigationPathInfo(hass, path, config);
if (newInfo.label !== current.label || newInfo.icon !== current.icon) {
current = newInfo;
onChange(newInfo);
}
});
}
return undefined;
};

View File

@@ -29,36 +29,6 @@ export const coverSupportsTiltPosition = (stateObj: CoverEntity) =>
export const coverSupportsAnyPosition = (stateObj: CoverEntity) =>
coverSupportsPosition(stateObj) || coverSupportsTiltPosition(stateObj);
export const normalizeCoverFavoritePositions = (
positions?: number[]
): number[] => {
if (!positions) {
return [];
}
const unique = new Set<number>();
const normalized: number[] = [];
for (const position of positions) {
const value = Number(position);
if (isNaN(value)) {
continue;
}
const clamped = Math.max(0, Math.min(100, Math.round(value)));
if (unique.has(clamped)) {
continue;
}
unique.add(clamped);
normalized.push(clamped);
}
return normalized;
};
export function isFullyOpen(stateObj: CoverEntity) {
if (stateObj.attributes.current_position !== undefined) {
return stateObj.attributes.current_position === 100;

View File

@@ -0,0 +1,27 @@
export const normalizeFavoritePositions = (positions?: number[]): number[] => {
if (!positions) {
return [];
}
const unique = new Set<number>();
const normalized: number[] = [];
for (const position of positions) {
const value = Number(position);
if (isNaN(value)) {
continue;
}
const clamped = Math.max(0, Math.min(100, value));
if (unique.has(clamped)) {
continue;
}
unique.add(clamped);
normalized.push(clamped);
}
return normalized;
};

View File

@@ -18,11 +18,19 @@ export interface CoreFrontendSystemData {
onboarded_date?: string;
}
export interface CustomShortcutItem {
path: string;
label?: string;
icon?: string;
color?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
welcome_banner_dismissed?: boolean;
hidden_summaries?: string[];
hide_welcome_message?: boolean;
custom_shortcuts?: CustomShortcutItem[];
}
declare global {

View File

@@ -1,5 +1,7 @@
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";
@@ -28,6 +30,25 @@ export const createHassioSession = async (
return setIngressCookie(restResponse.data.session);
};
export interface IngressPanelInfo {
title: string;
icon: string;
}
export type IngressPanelInfoMap = Record<string, IngressPanelInfo>;
export const getIngressPanelInfoCollection = (conn: Connection) =>
getCollection<IngressPanelInfoMap>(
conn,
"_ingressPanelInfo",
async (conn2) => {
const result = await supervisorApiWsRequest<{
panels: IngressPanelInfoMap;
}>(conn2, { endpoint: "/ingress/panels" });
return result.panels;
}
);
export const validateHassioSession = async (
hass: HomeAssistant,
session: string

View File

@@ -3,6 +3,7 @@ import type {
HassEntities,
HassEntity,
HassEntityAttributeBase,
MessageBase,
} from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
@@ -124,8 +125,8 @@ export const subscribeHistory = (
startTime: Date,
endTime: Date,
entityIds: string[]
): Promise<() => Promise<void>> => {
const params = {
): Promise<() => Promise<void>> =>
subscribeHistoryStream(hass, callbackFunction, () => ({
type: "history/stream",
entity_ids: entityIds,
start_time: startTime.toISOString(),
@@ -134,13 +135,7 @@ export const subscribeHistory = (
no_attributes: !entityIds.some((entityId) =>
entityIdHistoryNeedsAttributes(hass, entityId)
),
};
const stream = new HistoryStream(hass);
return hass.connection.subscribeMessage<HistoryStreamMessage>(
(message) => callbackFunction(stream.processMessage(message)),
params
);
};
}));
export class HistoryStream {
hass: HomeAssistant;
@@ -238,26 +233,81 @@ export const subscribeHistoryStatesTimeWindow = (
noAttributes?: boolean,
minimalResponse = true,
significantChangesOnly = true
): Promise<() => Promise<void>> => {
const params = {
type: "history/stream",
entity_ids: entityIds,
start_time: new Date(
new Date().getTime() - 60 * 60 * hoursToShow * 1000
).toISOString(),
minimal_response: minimalResponse,
significant_changes_only: significantChangesOnly,
no_attributes:
noAttributes ??
!entityIds.some((entityId) =>
entityIdHistoryNeedsAttributes(hass, entityId)
),
};
const stream = new HistoryStream(hass, hoursToShow);
return hass.connection.subscribeMessage<HistoryStreamMessage>(
(message) => callbackFunction(stream.processMessage(message)),
params
): Promise<() => Promise<void>> =>
subscribeHistoryStream(
hass,
callbackFunction,
() => ({
type: "history/stream",
entity_ids: entityIds,
// Recomputed on every (re)subscribe so the replay window stays anchored
// to "now" after a reconnect instead of replaying a stale window.
start_time: new Date(
new Date().getTime() - 60 * 60 * hoursToShow * 1000
).toISOString(),
minimal_response: minimalResponse,
significant_changes_only: significantChangesOnly,
no_attributes:
noAttributes ??
!entityIds.some((entityId) =>
entityIdHistoryNeedsAttributes(hass, entityId)
),
}),
hoursToShow
);
/**
* Subscribe to a history stream with transparent reconnect handling.
*
* Auto-resubscribe in home-assistant-js-websocket replays the original
* `subscribeMessage` call, which reuses the `HistoryStream` (its
* `combinedHistory` would merge stale state with the replayed stream) and the
* original `start_time` (which is stale after a disconnect for time-window
* subscriptions). Instead, we disable the library's auto-resubscribe and
* reimplement it here: on every `ready` event we build fresh params and start
* a fresh `HistoryStream`, so consumers don't need to listen for reconnects.
*/
const subscribeHistoryStream = async (
hass: HomeAssistant,
callbackFunction: (data: HistoryStates) => void,
buildParams: () => MessageBase,
hoursToShow?: number
): Promise<() => Promise<void>> => {
let currentUnsub: (() => Promise<void>) | undefined;
let disposed = false;
const doSubscribe = async () => {
const stream = new HistoryStream(hass, hoursToShow);
const unsub = await hass.connection.subscribeMessage<HistoryStreamMessage>(
(message) => callbackFunction(stream.processMessage(message)),
buildParams(),
{ resubscribe: false }
);
if (disposed) {
unsub().catch(() => undefined);
return;
}
currentUnsub = unsub;
};
const onReady = () => {
if (disposed) return;
currentUnsub = undefined;
// Reconnect failures (e.g. history component not yet loaded) are swallowed;
// consumers retry via their own component-availability logic.
doSubscribe().catch(() => undefined);
};
await doSubscribe();
hass.connection.addEventListener("ready", onReady);
return async () => {
disposed = true;
hass.connection.removeEventListener("ready", onReady);
if (currentUnsub) {
await currentUnsub();
}
};
};
const equalState = (obj1: LineChartState, obj2: LineChartState) =>

View File

@@ -24,6 +24,10 @@ export interface LawnMowerEntity extends HassEntityBase {
attributes: LawnMowerEntityAttributes;
}
export function isMowing(stateObj: LawnMowerEntity): boolean {
return stateObj.state === "mowing";
}
export function canStartMowing(stateObj: LawnMowerEntity): boolean {
if (stateObj.state === UNAVAILABLE) {
return false;

View File

@@ -19,6 +19,7 @@ export interface LovelaceBaseSectionConfig {
* @deprecated Use heading card instead.
*/
title?: string;
theme?: string;
}
export interface LovelaceSectionConfig extends LovelaceBaseSectionConfig {

View File

@@ -2,3 +2,21 @@ export interface LovelaceStrategyConfig {
type: string;
[key: string]: any;
}
/** Must stay aligned with `STRATEGIES.dashboard` in `panels/lovelace/strategies/get-strategy.ts`. */
export const LOVELACE_BUILTIN_DASHBOARD_STRATEGY_TYPES = [
"original-states",
"map",
"iframe",
"areas",
"home",
"energy",
] as const;
export type LovelaceBuiltinDashboardStrategyType =
(typeof LOVELACE_BUILTIN_DASHBOARD_STRATEGY_TYPES)[number];
/** Dashboard strategy id from the new-dashboard picker: built-in key or `custom:…`. */
export type LovelaceDashboardStrategyTypeId =
| LovelaceBuiltinDashboardStrategyType
| `custom:${string}`;

View File

@@ -1,3 +1,4 @@
import { titleCase } from "../../../common/string/title-case";
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { MediaSelectorValue } from "../../selector";
import type { LovelaceBadgeConfig } from "./badge";
@@ -93,3 +94,11 @@ export function isStrategyView(
): view is LovelaceStrategyViewConfig {
return "strategy" in view;
}
export const computeViewTitle = (
view: LovelaceBaseViewConfig,
index: number
): string => view.title ?? (view.path ? titleCase(view.path) : String(index));
export const computeViewIcon = (view: LovelaceBaseViewConfig): string =>
view.icon ?? "mdi:view-compact";

View File

@@ -34,6 +34,12 @@ export interface LovelaceDashboardCreateParams extends LovelaceDashboardMutableP
mode: "storage";
}
/** Optional suggested values for dashboard creation (for example from a strategy). */
export interface LovelaceDashboardSuggestions {
title?: string;
icon?: string;
}
export const fetchDashboards = (
hass: HomeAssistant
): Promise<LovelaceDashboard[]> =>

View File

@@ -0,0 +1,67 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import {
DEFAULT_NAVIGATION_PATH_INFO,
subscribeNavigationPathInfo,
type NavigationPathInfo,
} from "./compute-navigation-path-info";
import type { HomeAssistant } from "../types";
/**
* Reactive controller that keeps `NavigationPathInfo` in sync with a
* navigation path. Resolves synchronously first, then subscribes to
* lovelace config updates for view paths.
*/
export class NavigationPathInfoController implements ReactiveController {
private _host: ReactiveControllerHost;
private _hass?: HomeAssistant;
private _info: NavigationPathInfo = DEFAULT_NAVIGATION_PATH_INFO;
private _unsub?: UnsubscribeFunc;
private _subscribedPath?: string;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
get info(): NavigationPathInfo {
return this._info;
}
update(hass: HomeAssistant, path: string | undefined): void {
this._hass = hass;
if (path === this._subscribedPath) return;
this._unsub?.();
this._unsub = undefined;
this._subscribedPath = path;
if (!path) {
this._info = DEFAULT_NAVIGATION_PATH_INFO;
return;
}
this._unsub = subscribeNavigationPathInfo(hass, path, (info) => {
this._info = info;
this._host.requestUpdate();
});
}
hostConnected(): void {
if (this._hass && this._subscribedPath && !this._unsub) {
const path = this._subscribedPath;
this._subscribedPath = undefined;
this.update(this._hass, path);
}
}
hostDisconnected(): void {
this._unsub?.();
this._unsub = undefined;
}
}

View File

@@ -77,6 +77,7 @@ export type Selector =
| TriggerSelector
| TTSSelector
| TTSVoiceSelector
| SerialSelector
| UiActionSelector
| UiColorSelector
| UiStateContentSelector
@@ -451,6 +452,10 @@ export interface SelectorSelector {
selector: {} | null;
}
export interface SerialSelector {
serial: {} | null;
}
export interface StateSelector {
state: {
extra_options?: { label: string; value: any }[];
@@ -494,6 +499,7 @@ export interface StringSelector {
| "color";
prefix?: string;
suffix?: string;
placeholder?: string;
autocomplete?: string;
multiple?: true;
} | null;

View File

@@ -32,8 +32,13 @@ export const getSensorNumericDeviceClasses = async (
if (sensorNumericDeviceClassesCache) {
return sensorNumericDeviceClassesCache;
}
sensorNumericDeviceClassesCache = hass.callWS({
type: "sensor/numeric_device_classes",
});
sensorNumericDeviceClassesCache = hass
.callWS<SensorNumericDeviceClasses>({
type: "sensor/numeric_device_classes",
})
.catch((err: Error) => {
sensorNumericDeviceClassesCache = undefined;
throw err;
});
return sensorNumericDeviceClassesCache!;
};

View File

@@ -1,4 +1,16 @@
import type { HomeAssistant } from "../types";
export interface SerialPort {
device: string;
serial_number: string | null;
manufacturer: string | null;
description: string | null;
vid?: string;
pid?: string;
}
export const scanUSBDevices = (hass: HomeAssistant) =>
hass.callWS({ type: "usb/scan" });
export const listSerialPorts = (hass: HomeAssistant) =>
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });

View File

@@ -19,36 +19,6 @@ export const DEFAULT_VALVE_FAVORITE_POSITIONS = [0, 25, 75, 100];
export const valveSupportsPosition = (stateObj: ValveEntity) =>
supportsFeature(stateObj, ValveEntityFeature.SET_POSITION);
export const normalizeValveFavoritePositions = (
positions?: number[]
): number[] => {
if (!positions) {
return [];
}
const unique = new Set<number>();
const normalized: number[] = [];
for (const position of positions) {
const value = Number(position);
if (isNaN(value)) {
continue;
}
const clamped = Math.max(0, Math.min(100, Math.round(value)));
if (unique.has(clamped)) {
continue;
}
unique.add(clamped);
normalized.push(clamped);
}
return normalized;
};
export function isFullyOpen(stateObj: ValveEntity) {
if (
stateObj.attributes.current_position !== undefined &&

View File

@@ -90,7 +90,7 @@ class DialogBox extends LitElement {
></ha-icon-button
></slot>`
: nothing}
<span
<h1
class=${classMap({ title: true, alert: confirmPrompt })}
slot="title"
id="dialog-box-title"
@@ -102,7 +102,7 @@ class DialogBox extends LitElement {
></ha-svg-icon> `
: nothing}
${dialogTitle}
</span>
</h1>
${this._params.subtitle
? html`<span slot="subtitle">${this._params.subtitle}</span>`
: nothing}
@@ -248,6 +248,11 @@ class DialogBox extends LitElement {
ha-input {
width: 100%;
}
.title {
font-weight: inherit;
font-size: inherit;
margin: inherit;
}
.title.alert {
padding: 0 var(--ha-space-2);
}

View File

@@ -11,8 +11,8 @@ import {
DEFAULT_COVER_FAVORITE_POSITIONS,
coverSupportsPosition,
coverSupportsTiltPosition,
normalizeCoverFavoritePositions,
} from "../../../../data/cover";
import { normalizeFavoritePositions } from "../../../../data/favorite_positions";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity/entity_attributes";
import type {
@@ -67,13 +67,13 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
const options = this.entry.options?.cover;
this._favoritePositions = coverSupportsPosition(this.stateObj)
? normalizeCoverFavoritePositions(
? normalizeFavoritePositions(
options?.favorite_positions ?? DEFAULT_COVER_FAVORITE_POSITIONS
)
: [];
this._favoriteTiltPositions = coverSupportsTiltPosition(this.stateObj)
? normalizeCoverFavoritePositions(
? normalizeFavoritePositions(
options?.favorite_tilt_positions ?? DEFAULT_COVER_FAVORITE_POSITIONS
)
: [];
@@ -103,7 +103,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
? this.stateObj.attributes.current_position
: this.stateObj.attributes.current_tilt_position;
return current == null ? undefined : Math.round(current);
return current == null ? undefined : current;
}
private async _save(options: CoverEntityOptions): Promise<void> {
@@ -142,7 +142,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
kind: FavoriteKind,
favorites: number[]
): Promise<void> {
const normalized = normalizeCoverFavoritePositions(favorites);
const normalized = normalizeFavoritePositions(favorites);
if (kind === "position") {
this._favoritePositions = normalized;
@@ -213,7 +213,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
return undefined;
}
return Math.max(0, Math.min(100, Math.round(number)));
return Math.max(0, Math.min(100, number));
}
private async _addFavorite(kind: FavoriteKind): Promise<void> {

View File

@@ -48,22 +48,27 @@ export class HaMoreInfoStateHeader extends LitElement {
return html`
<p class="state">${stateDisplay}</p>
<p class="last-changed" @click=${this._toggleAbsolute}>
${this._absoluteTime
? html`
<ha-absolute-time
.hass=${this.hass}
.datetime=${this.changedOverride ?? this.stateObj.last_changed}
></ha-absolute-time>
`
: html`
<ha-relative-time
.hass=${this.hass}
.datetime=${this.changedOverride ?? this.stateObj.last_changed}
capitalize
></ha-relative-time>
`}
</p>
<div class="time-row">
<p class="last-changed" @click=${this._toggleAbsolute}>
${this._absoluteTime
? html`
<ha-absolute-time
.hass=${this.hass}
.datetime=${this.changedOverride ??
this.stateObj.last_changed}
></ha-absolute-time>
`
: html`
<ha-relative-time
.hass=${this.hass}
.datetime=${this.changedOverride ??
this.stateObj.last_changed}
capitalize
></ha-relative-time>
`}
</p>
<slot name="after-time"></slot>
</div>
`;
}
@@ -78,6 +83,19 @@ export class HaMoreInfoStateHeader extends LitElement {
font-size: 36px;
line-height: var(--ha-line-height-condensed);
}
.time-row {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--ha-space-5);
}
::slotted([slot="after-time"]) {
position: absolute;
inset-inline-end: 0;
top: 50%;
transform: translateY(-50%);
}
.last-changed {
font-style: normal;
font-size: var(--ha-font-size-l);
@@ -85,7 +103,6 @@ export class HaMoreInfoStateHeader extends LitElement {
line-height: var(--ha-line-height-normal);
letter-spacing: 0.1px;
padding: var(--ha-space-1) 0;
margin-bottom: var(--ha-space-5);
cursor: pointer;
user-select: none;
-webkit-user-select: none;

View File

@@ -0,0 +1,67 @@
import { mdiCogOutline } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-icon-button";
import { getExtendedEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import { showVacuumSegmentMappingView } from "./show-view-vacuum-segment-mapping";
@customElement("ha-more-info-view-vacuum-clean-areas-header-action")
export class HaMoreInfoViewVacuumCleanAreasHeaderAction extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params!: { entityId: string };
@state() private _hasMapping = false;
protected firstUpdated() {
this._loadMapping();
}
private async _loadMapping() {
if (!this.params.entityId || !this.hass.user?.is_admin) return;
try {
const entry = await getExtendedEntityRegistryEntry(
this.hass,
this.params.entityId
);
const areaMapping = entry?.options?.vacuum?.area_mapping;
this._hasMapping =
!!areaMapping &&
Object.keys(areaMapping).some((areaId) => this.hass.areas[areaId]);
} catch (err) {
this._hasMapping = false;
// eslint-disable-next-line no-console
console.error("Failed to load area mapping", err);
}
}
protected render() {
if (!this.hass.user?.is_admin || !this._hasMapping) {
return nothing;
}
return html`
<ha-icon-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.configure_area_mapping"
)}
.path=${mdiCogOutline}
@click=${this._handleClick}
></ha-icon-button>
`;
}
private _handleClick() {
showVacuumSegmentMappingView(
this,
this.hass.localize,
this.params.entityId
);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-vacuum-clean-areas-header-action": HaMoreInfoViewVacuumCleanAreasHeaderAction;
}
}

View File

@@ -0,0 +1,372 @@
import { mdiCogOutline, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-icon";
import "../../../../components/ha-spinner";
import "../../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
import {
getExtendedEntityRegistryEntry,
type ExtEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import { showVacuumSegmentMappingView } from "./show-view-vacuum-segment-mapping";
@customElement("ha-more-info-view-vacuum-clean-areas")
export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params!: { entityId: string };
@state() private _mappedAreaIds?: string[];
@state() private _selectedAreaIds: string[] = [];
@state() private _loading = true;
@state() private _error?: string;
@state() private _submitting = false;
protected firstUpdated() {
this._loadAreas();
}
private async _loadAreas() {
if (!this.params.entityId) return;
this._loading = true;
this._error = undefined;
try {
const entry: ExtEntityRegistryEntry =
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
const areaMapping = entry?.options?.vacuum?.area_mapping || {};
this._mappedAreaIds = Object.keys(areaMapping).filter(
(areaId) => this.hass.areas[areaId]
);
} catch (err: any) {
this._error = err.message || "Failed to load areas";
} finally {
this._loading = false;
}
}
private _toggleArea(ev: Event) {
const areaId = (ev.currentTarget as HTMLElement).dataset.areaId!;
const index = this._selectedAreaIds.indexOf(areaId);
if (index >= 0) {
this._selectedAreaIds = this._selectedAreaIds.filter(
(id) => id !== areaId
);
} else {
this._selectedAreaIds = [...this._selectedAreaIds, areaId];
}
}
private async _startCleaning() {
if (!this.params.entityId || this._selectedAreaIds.length === 0) return;
this._submitting = true;
try {
await this.hass.callService("vacuum", "clean_area", {
entity_id: this.params.entityId,
cleaning_area_id: this._selectedAreaIds,
});
this._selectedAreaIds = [];
fireEvent(this, "close-child-view");
} catch (err: any) {
this._error = err.message || "Failed to start cleaning";
} finally {
this._submitting = false;
}
}
private _openSegmentMapping() {
showVacuumSegmentMappingView(
this,
this.hass.localize,
this.params.entityId
);
}
private _renderAreaCard(areaId: string) {
const area: AreaRegistryEntry | undefined = this.hass.areas[areaId];
if (!area) return nothing;
const selectionIndex = this._selectedAreaIds.indexOf(areaId);
const isSelected = selectionIndex >= 0;
return html`
<div
class="area-card ${isSelected ? "selected" : ""}"
data-area-id=${areaId}
@click=${this._toggleArea}
>
${isSelected
? html`<span class="badge">${selectionIndex + 1}</span>`
: nothing}
<div class="area-icon">
${area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
</div>
<div class="area-name">${area.name}</div>
</div>
`;
}
protected render() {
if (this._loading) {
return html`
<div class="center">
<ha-spinner></ha-spinner>
</div>
`;
}
if (this._error) {
return html`
<div class="content">
<ha-alert alert-type="error">${this._error}</ha-alert>
</div>
`;
}
if (!this._mappedAreaIds || this._mappedAreaIds.length === 0) {
return html`
<div class="content empty-content">
<div class="empty">
<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>
<p class="empty-title">
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.no_areas_header"
)}
</p>
<p>
${this.hass.localize(
this.hass.user?.is_admin
? "ui.dialogs.more_info_control.vacuum.no_areas_text"
: "ui.dialogs.more_info_control.vacuum.no_areas_text_non_admin"
)}
</p>
${this.hass.user?.is_admin
? html`
<ha-button
appearance="plain"
size="small"
@click=${this._openSegmentMapping}
>
<ha-svg-icon
slot="start"
.path=${mdiCogOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.configure"
)}
</ha-button>
`
: nothing}
</div>
</div>
`;
}
return html`
<div class="content">
<div class="area-grid">
${this._mappedAreaIds.map((areaId) => this._renderAreaCard(areaId))}
</div>
<p class="hint">
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.clean_areas_order_hint"
)}
</p>
</div>
<div class="footer">
<ha-button
@click=${this._startCleaning}
.disabled=${this._selectedAreaIds.length === 0 || this._submitting}
>
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.start_cleaning_areas"
)}
${this._selectedAreaIds.length > 0
? ` (${this._selectedAreaIds.length})`
: nothing}
</ha-button>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.center {
display: flex;
align-items: center;
justify-content: center;
padding: var(--ha-space-8);
}
.content {
flex: 1;
overflow-y: auto;
padding: var(--ha-space-4);
}
.empty-content {
display: flex;
align-items: center;
justify-content: center;
}
.empty {
--mdc-icon-size: 48px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--secondary-text-color);
padding: var(--ha-space-8) var(--ha-space-4);
max-width: 420px;
}
.empty ha-button {
--mdc-icon-size: 18px;
}
.empty .empty-title {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
margin: var(--ha-space-3) 0 var(--ha-space-2);
}
.empty p {
margin: 0 0 var(--ha-space-4);
}
.area-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--ha-space-3);
}
.area-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--ha-space-2);
padding: 12px;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background: var(--card-background-color, #fff);
border: 1px solid var(--divider-color);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
min-height: 80px;
color: var(--primary-text-color);
}
.area-card::before {
content: "";
display: block;
inset: 0;
position: absolute;
background-color: transparent;
pointer-events: none;
opacity: 0.2;
transition:
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.area-card:hover::before {
background-color: var(--divider-color);
}
.area-card.selected::before {
background-color: var(--primary-color);
}
.area-card .badge {
position: absolute;
top: 6px;
inset-inline-end: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: var(--primary-color);
color: var(--text-primary-color);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-medium);
z-index: 1;
}
.area-icon {
--mdc-icon-size: 28px;
color: var(--secondary-text-color);
}
.area-card.selected .area-icon {
color: var(--primary-color);
}
.area-name {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--ha-space-8);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
text-align: center;
line-height: var(--ha-line-height-condensed);
word-break: break-word;
}
.hint {
margin: var(--ha-space-3) 0 0;
text-align: center;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.footer {
display: flex;
justify-content: flex-end;
padding: var(--ha-space-4);
border-top: 1px solid var(--divider-color);
background: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
position: sticky;
bottom: 0;
z-index: 10;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-vacuum-clean-areas": HaMoreInfoViewVacuumCleanAreas;
}
}

View File

@@ -1,6 +1,7 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-spinner";
import "../../../../components/ha-vacuum-segment-area-mapper";
@@ -77,6 +78,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
options: options,
});
this._dirty = false;
fireEvent(this, "close-child-view");
} catch (err: any) {
this._error = err.message;
} finally {

View File

@@ -0,0 +1,23 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
export const loadVacuumCleanAreasView = () =>
import("./ha-more-info-view-vacuum-clean-areas");
export const loadVacuumCleanAreasHeaderAction = () =>
import("./ha-more-info-view-vacuum-clean-areas-header-action");
export const showVacuumCleanAreasView = (
element: HTMLElement,
localize: LocalizeFunc,
entityId: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-vacuum-clean-areas",
viewImport: loadVacuumCleanAreasView,
viewTitle: localize("ui.dialogs.more_info_control.vacuum.clean_areas"),
viewParams: { entityId },
viewHeaderTag: "ha-more-info-view-vacuum-clean-areas-header-action",
viewHeaderImport: loadVacuumCleanAreasHeaderAction,
});
};

View File

@@ -15,10 +15,8 @@ import type {
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import type { ValveEntity } from "../../../../data/valve";
import {
DEFAULT_VALVE_FAVORITE_POSITIONS,
normalizeValveFavoritePositions,
} from "../../../../data/valve";
import { DEFAULT_VALVE_FAVORITE_POSITIONS } from "../../../../data/valve";
import { normalizeFavoritePositions } from "../../../../data/favorite_positions";
import {
showConfirmationDialog,
showPromptDialog,
@@ -55,7 +53,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
this.entry &&
this.stateObj
) {
this._favoritePositions = normalizeValveFavoritePositions(
this._favoritePositions = normalizeFavoritePositions(
this.entry.options?.valve?.favorite_positions ??
DEFAULT_VALVE_FAVORITE_POSITIONS
);
@@ -75,7 +73,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
private _currentValue(): number | undefined {
const current = this.stateObj.attributes.current_position;
return current == null ? undefined : Math.round(current);
return current == null ? undefined : current;
}
private async _save(favorite_positions: number[]): Promise<void> {
@@ -105,7 +103,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
}
private async _setFavorites(favorites: number[]): Promise<void> {
const normalized = normalizeValveFavoritePositions(favorites);
const normalized = normalizeFavoritePositions(favorites);
this._favoritePositions = normalized;
await this._save(normalized);
}
@@ -155,7 +153,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
return undefined;
}
return Math.max(0, Math.min(100, Math.round(number)));
return Math.max(0, Math.min(100, number));
}
private async _addFavorite(): Promise<void> {

View File

@@ -42,11 +42,13 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"fan",
"humidifier",
"input_boolean",
"lawn_mower",
"light",
"lock",
"siren",
"script",
"switch",
"vacuum",
"valve",
"water_heater",
"weather",

View File

@@ -1,4 +1,5 @@
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -6,47 +7,27 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain"
import { supportsFeature } from "../../../common/entity/supports-feature";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-svg-icon";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
import {
findBatteryChargingEntity,
findBatteryEntity,
} from "../../../data/entity/entity_registry";
import { forwardHaptic } from "../../../data/haptics";
import type { LawnMowerEntity } from "../../../data/lawn_mower";
import { LawnMowerEntityFeature } from "../../../data/lawn_mower";
import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
isMowing,
} from "../../../data/lawn_mower";
import "../../../state-control/lawn_mower/ha-state-control-lawn_mower-status";
import type { HomeAssistant } from "../../../types";
interface LawnMowerCommand {
translationKey: string;
icon: string;
serviceName: string;
isVisible: (stateObj: LawnMowerEntity) => boolean;
}
const LAWN_MOWER_COMMANDS: LawnMowerCommand[] = [
{
translationKey: "start_mowing",
icon: mdiPlay,
serviceName: "start_mowing",
isVisible: (stateObj) =>
supportsFeature(stateObj, LawnMowerEntityFeature.START_MOWING),
},
{
translationKey: "pause",
icon: mdiPause,
serviceName: "pause",
isVisible: (stateObj) =>
supportsFeature(stateObj, LawnMowerEntityFeature.PAUSE),
},
{
translationKey: "dock",
icon: mdiHomeImportOutline,
serviceName: "dock",
isVisible: (stateObj) =>
supportsFeature(stateObj, LawnMowerEntityFeature.DOCK),
},
];
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@customElement("more-info-lawn_mower")
class MoreInfoLawnMower extends LitElement {
@@ -54,63 +35,6 @@ class MoreInfoLawnMower extends LitElement {
@property({ attribute: false }) public stateObj?: LawnMowerEntity;
protected render() {
if (!this.hass || !this.stateObj) {
return nothing;
}
const stateObj = this.stateObj;
return html`
${stateObj.state !== UNAVAILABLE
? html` <div class="flex-horizontal">
<div>
<span class="status-subtitle"
>${this.hass!.localize(
"ui.dialogs.more_info_control.lawn_mower.activity"
)}:
</span>
<span>
<strong>${this.hass.formatEntityState(stateObj)}</strong>
</span>
</div>
${this._renderBattery()}
</div>`
: nothing}
${LAWN_MOWER_COMMANDS.some((item) => item.isVisible(stateObj))
? html`
<div>
<p></p>
<div class="status-subtitle">
${this.hass!.localize(
"ui.dialogs.more_info_control.lawn_mower.commands"
)}
</div>
<div class="flex-horizontal space-around">
${LAWN_MOWER_COMMANDS.filter((item) =>
item.isVisible(stateObj)
).map(
(item) => html`
<div>
<ha-icon-button
.path=${item.icon}
.entry=${item}
@click=${this._callService}
.label=${this.hass!.localize(
`ui.dialogs.more_info_control.lawn_mower.${item.translationKey}`
)}
.disabled=${stateObj.state === UNAVAILABLE}
></ha-icon-button>
</div>
`
)}
</div>
</div>
`
: ""}
`;
}
private _deviceEntities = memoizeOne(
(
deviceId: string,
@@ -121,11 +45,45 @@ class MoreInfoLawnMower extends LitElement {
}
);
private get _supportsStartPause(): boolean {
if (!this.stateObj) return false;
return (
supportsFeature(this.stateObj, LawnMowerEntityFeature.START_MOWING) ||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
);
}
private get _startPauseIcon(): string {
if (!this.stateObj) return mdiPlay;
return isMowing(this.stateObj) &&
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
? mdiPause
: mdiPlay;
}
private get _startPauseLabel(): string {
if (!this.stateObj || !this.hass) return "";
return isMowing(this.stateObj) &&
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
? this.hass.localize("ui.dialogs.more_info_control.lawn_mower.pause")
: this.hass.localize(
"ui.dialogs.more_info_control.lawn_mower.start_mowing"
);
}
private get _startPauseDisabled(): boolean {
if (!this.stateObj) return true;
if (this.stateObj.state === UNAVAILABLE) return true;
if (isMowing(this.stateObj)) return false;
return !canStartMowing(this.stateObj);
}
private _renderBattery() {
const stateObj = this.stateObj!;
const deviceId = this.hass.entities[stateObj.entity_id]?.device_id;
if (!this.stateObj || !this.hass) {
return nothing;
}
const deviceId = this.hass.entities[this.stateObj.entity_id]?.device_id;
const entities = deviceId
? this._deviceEntities(deviceId, this.hass.entities)
: [];
@@ -134,12 +92,12 @@ class MoreInfoLawnMower extends LitElement {
const battery = batteryEntity
? this.hass.states[batteryEntity.entity_id]
: undefined;
const batteryDomain = battery ? computeStateDomain(battery) : undefined;
const batteryIsBinary =
battery && computeStateDomain(battery) === "binary_sensor";
// Use device battery entity
if (battery && (batteryIsBinary || !isNaN(battery.state as any))) {
if (
battery &&
(batteryDomain === "binary_sensor" || !isNaN(battery.state as any))
) {
const batteryChargingEntity = findBatteryChargingEntity(
this.hass,
entities
@@ -149,49 +107,145 @@ class MoreInfoLawnMower extends LitElement {
: undefined;
return html`
<div>
<span>
${batteryIsBinary
? ""
: `${Number(battery.state).toFixed()}${blankBeforePercent(
<span class="battery" slot="after-time">
${batteryDomain === "binary_sensor"
? nothing
: html`<span
>${Number(battery.state).toFixed()}${blankBeforePercent(
this.hass.locale
)}%`}
<ha-battery-icon
.hass=${this.hass}
.batteryStateObj=${battery}
.batteryChargingStateObj=${batteryCharging}
></ha-battery-icon>
</span>
</div>
)}%</span
>`}
<ha-battery-icon
.hass=${this.hass}
.batteryStateObj=${battery}
.batteryChargingStateObj=${batteryCharging}
></ha-battery-icon>
</span>
`;
}
return nothing;
}
private _callService(ev: CustomEvent) {
const entry = (ev.target! as any).entry as LawnMowerCommand;
this.hass.callService("lawn_mower", entry.serviceName, {
entity_id: this.stateObj!.entity_id,
private _handleStartPause() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
if (isMowing(this.stateObj)) {
this.hass.callService("lawn_mower", "pause", {
entity_id: this.stateObj.entity_id,
});
} else {
this.hass.callService("lawn_mower", "start_mowing", {
entity_id: this.stateObj.entity_id,
});
}
}
private _handleDock() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
this.hass.callService("lawn_mower", "dock", {
entity_id: this.stateObj.entity_id,
});
}
static styles = css`
:host {
line-height: var(--ha-line-height-normal);
protected render() {
if (!this.stateObj) {
return nothing;
}
.status-subtitle {
color: var(--secondary-text-color);
}
.flex-horizontal {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.space-around {
justify-content: space-around;
}
`;
const stateObj = this.stateObj;
const isUnavailable = stateObj.state === UNAVAILABLE;
const supportsDock = supportsFeature(stateObj, LawnMowerEntityFeature.DOCK);
const hasAnyCommand = this._supportsStartPause || supportsDock;
return html`
<ha-more-info-state-header .hass=${this.hass} .stateObj=${this.stateObj}>
${this._renderBattery()}
</ha-more-info-state-header>
<div class="controls">
<ha-state-control-lawn_mower-status
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-lawn_mower-status>
${hasAnyCommand
? html`
<div class="buttons">
<ha-control-button-group>
${this._supportsStartPause
? html`
<ha-control-button
.label=${this._startPauseLabel}
@click=${this._handleStartPause}
.disabled=${this._startPauseDisabled}
>
<ha-svg-icon
.path=${this._startPauseIcon}
></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsDock
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.lawn_mower.dock"
)}
@click=${this._handleDock}
.disabled=${isUnavailable || !canDock(stateObj)}
>
<ha-svg-icon
.path=${mdiHomeImportOutline}
></ha-svg-icon>
</ha-control-button>
`
: nothing}
</ha-control-button-group>
</div>
`
: nothing}
</div>
`;
}
static get styles(): CSSResultGroup {
return [
moreInfoControlStyle,
css`
.battery {
display: inline-flex;
align-items: center;
gap: var(--ha-space-1);
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
--mdc-icon-size: 18px;
}
.battery span {
height: var(--mdc-icon-size);
line-height: var(--mdc-icon-size);
}
ha-state-control-lawn_mower-status {
margin-bottom: var(--ha-space-4);
}
ha-control-button-group {
--control-button-group-thickness: 48px;
justify-content: center;
}
ha-control-button-group ha-control-button {
flex: 0 0 auto;
width: var(--control-button-group-thickness);
--control-button-border-radius: var(--ha-border-radius-lg);
}
`,
];
}
}
declare global {

View File

@@ -7,7 +7,6 @@ import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-checkbox";
import "../../../components/ha-faded";
import "../../../components/ha-markdown";
import "../../../components/ha-md-list";

View File

@@ -1,4 +1,5 @@
import {
mdiChevronRight,
mdiFan,
mdiHomeImportOutline,
mdiMapMarker,
@@ -7,93 +8,43 @@ import {
mdiPlayPause,
mdiStop,
mdiTargetVariant,
mdiTextureBox,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/entity/ha-battery-icon";
import type { HaSelectSelectEvent } from "../../../components/ha-select";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-select-menu";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-select";
import "../../../components/ha-svg-icon";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
import {
findBatteryChargingEntity,
findBatteryEntity,
} from "../../../data/entity/entity_registry";
import { forwardHaptic } from "../../../data/haptics";
import type { VacuumEntity } from "../../../data/vacuum";
import { VacuumEntityFeature } from "../../../data/vacuum";
import {
VacuumEntityFeature,
canReturnHome,
canStart,
canStop,
isCleaning,
} from "../../../data/vacuum";
import "../../../state-control/vacuum/ha-state-control-vacuum-status";
import type { HomeAssistant } from "../../../types";
interface VacuumCommand {
translationKey: string;
icon: string;
serviceName: string;
isVisible: (stateObj: VacuumEntity) => boolean;
}
const VACUUM_COMMANDS: VacuumCommand[] = [
{
translationKey: "start",
icon: mdiPlay,
serviceName: "start",
isVisible: (stateObj) =>
supportsFeature(stateObj, VacuumEntityFeature.START),
},
{
translationKey: "pause",
icon: mdiPause,
serviceName: "pause",
isVisible: (stateObj) =>
// We need also to check if Start is supported because if not we show start-pause
// Start-pause service is only available for old vacuum entities, new entities have the `STATE` feature
supportsFeature(stateObj, VacuumEntityFeature.PAUSE) &&
(supportsFeature(stateObj, VacuumEntityFeature.STATE) ||
supportsFeature(stateObj, VacuumEntityFeature.START)),
},
{
translationKey: "start_pause",
icon: mdiPlayPause,
serviceName: "start_pause",
isVisible: (stateObj) =>
// If start is supported, we don't show this button
// This service is only available for old vacuum entities, new entities have the `STATE` feature
!supportsFeature(stateObj, VacuumEntityFeature.STATE) &&
!supportsFeature(stateObj, VacuumEntityFeature.START) &&
supportsFeature(stateObj, VacuumEntityFeature.PAUSE),
},
{
translationKey: "stop",
icon: mdiStop,
serviceName: "stop",
isVisible: (stateObj) =>
supportsFeature(stateObj, VacuumEntityFeature.STOP),
},
{
translationKey: "clean_spot",
icon: mdiTargetVariant,
serviceName: "clean_spot",
isVisible: (stateObj) =>
supportsFeature(stateObj, VacuumEntityFeature.CLEAN_SPOT),
},
{
translationKey: "locate",
icon: mdiMapMarker,
serviceName: "locate",
isVisible: (stateObj) =>
supportsFeature(stateObj, VacuumEntityFeature.LOCATE),
},
{
translationKey: "return_home",
icon: mdiHomeImportOutline,
serviceName: "return_to_base",
isVisible: (stateObj) =>
supportsFeature(stateObj, VacuumEntityFeature.RETURN_HOME),
},
];
import "../components/ha-more-info-control-select-container";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import { showVacuumCleanAreasView } from "../components/vacuum/show-view-vacuum-clean-areas";
@customElement("more-info-vacuum")
class MoreInfoVacuum extends LitElement {
@@ -101,107 +52,6 @@ class MoreInfoVacuum extends LitElement {
@property({ attribute: false }) public stateObj?: VacuumEntity;
protected render() {
if (!this.hass || !this.stateObj) {
return nothing;
}
const stateObj = this.stateObj;
return html`
${stateObj.state !== UNAVAILABLE
? html` <div class="flex-horizontal">
<div>
<span class="status-subtitle"
>${this.hass!.localize(
"ui.dialogs.more_info_control.vacuum.status"
)}:
</span>
<span>
<strong>
${supportsFeature(stateObj, VacuumEntityFeature.STATUS) &&
stateObj.attributes.status
? this.hass.formatEntityAttributeValue(stateObj, "status")
: this.hass.formatEntityState(stateObj)}
</strong>
</span>
</div>
${this._renderBattery()}
</div>`
: ""}
${VACUUM_COMMANDS.some((item) => item.isVisible(stateObj))
? html`
<div>
<p></p>
<div class="status-subtitle">
${this.hass!.localize(
"ui.dialogs.more_info_control.vacuum.commands"
)}
</div>
<div class="flex-horizontal space-around">
${VACUUM_COMMANDS.filter((item) =>
item.isVisible(stateObj)
).map(
(item) => html`
<div>
<ha-icon-button
.path=${item.icon}
.entry=${item}
@click=${this._callService}
.label=${this.hass!.localize(
`ui.dialogs.more_info_control.vacuum.${item.translationKey}`
)}
.disabled=${stateObj.state === UNAVAILABLE}
></ha-icon-button>
</div>
`
)}
</div>
</div>
`
: ""}
${supportsFeature(stateObj, VacuumEntityFeature.FAN_SPEED)
? html`
<div>
<div class="flex-horizontal">
<ha-select
.label=${this.hass!.localize(
"ui.dialogs.more_info_control.vacuum.fan_speed"
)}
.disabled=${stateObj.state === UNAVAILABLE}
.value=${stateObj.attributes.fan_speed}
@selected=${this._handleFanSpeedChanged}
.options=${stateObj.attributes.fan_speed_list!.map(
(mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"fan_speed",
mode
),
})
)}
>
</ha-select>
<div
style="justify-content: center; align-self: center; padding-top: 1.3em"
>
<span>
<ha-svg-icon .path=${mdiFan}></ha-svg-icon>
${this.hass.formatEntityAttributeValue(
stateObj,
"fan_speed"
)}
</span>
</div>
</div>
<p></p>
</div>
`
: ""}
`;
}
private _deviceEntities = memoizeOne(
(
deviceId: string,
@@ -212,11 +62,27 @@ class MoreInfoVacuum extends LitElement {
}
);
private get _stateOverride(): string | undefined {
if (!this.stateObj || !this.hass) {
return undefined;
}
if (
supportsFeature(this.stateObj, VacuumEntityFeature.STATUS) &&
this.stateObj.attributes.status
) {
return this.hass.formatEntityAttributeValue(this.stateObj, "status");
}
return undefined;
}
private _renderBattery() {
const stateObj = this.stateObj!;
const deviceId = this.hass.entities[stateObj.entity_id]?.device_id;
if (!this.stateObj || !this.hass) {
return nothing;
}
const deviceId = this.hass.entities[this.stateObj.entity_id]?.device_id;
const entities = deviceId
? this._deviceEntities(deviceId, this.hass.entities)
: [];
@@ -241,81 +107,474 @@ class MoreInfoVacuum extends LitElement {
: undefined;
return html`
<div>
<span>
${batteryDomain === "sensor"
? this.hass.formatEntityState(battery)
: nothing}
<ha-battery-icon
.hass=${this.hass}
.batteryStateObj=${battery}
.batteryChargingStateObj=${batteryCharging}
></ha-battery-icon>
</span>
</div>
<span class="battery" slot="after-time">
${batteryDomain === "binary_sensor"
? nothing
: html`<span
>${Number(battery.state).toFixed()}${blankBeforePercent(
this.hass.locale
)}%</span
>`}
<ha-battery-icon
.hass=${this.hass}
.batteryStateObj=${battery}
.batteryChargingStateObj=${batteryCharging}
></ha-battery-icon>
</span>
`;
}
// Use battery_level and battery_icon deprecated attributes
// Use deprecated battery_level and battery_icon attributes
if (
supportsFeature(stateObj, VacuumEntityFeature.BATTERY) &&
stateObj.attributes.battery_level
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
this.stateObj.attributes.battery_level
) {
return html`
<div>
<span>
${this.hass.formatEntityAttributeValue(
stateObj,
"battery_level",
Math.round(stateObj.attributes.battery_level)
)}
<ha-icon .icon=${stateObj.attributes.battery_icon}></ha-icon>
</span>
</div>
<span class="battery" slot="after-time">
<span
>${Math.round(
this.stateObj.attributes.battery_level
)}${blankBeforePercent(this.hass.locale)}%</span
>
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
</span>
`;
}
return nothing;
}
private _callService(ev: CustomEvent) {
const entry = (ev.target! as any).entry as VacuumCommand;
this.hass.callService("vacuum", entry.serviceName, {
private _callVacuumService(service: string) {
forwardHaptic(this, "light");
this.hass.callService("vacuum", service, {
entity_id: this.stateObj!.entity_id,
});
}
private _handleFanSpeedChanged(ev: HaSelectSelectEvent) {
const oldVal = this.stateObj!.attributes.fan_speed;
const newVal = ev.detail.value;
private _handleStartPause() {
const stateObj = this.stateObj!;
if (!newVal || oldVal === newVal) {
// Legacy start_pause for old vacuum entities without STATE feature
if (
!supportsFeature(stateObj, VacuumEntityFeature.STATE) &&
!supportsFeature(stateObj, VacuumEntityFeature.START) &&
supportsFeature(stateObj, VacuumEntityFeature.PAUSE)
) {
this._callVacuumService("start_pause");
return;
}
if (isCleaning(stateObj)) {
this._callVacuumService("pause");
} else {
this._callVacuumService("start");
}
}
private _handleStop() {
this._callVacuumService("stop");
}
private _handleReturnHome() {
this._callVacuumService("return_to_base");
}
private _handleLocate() {
this._callVacuumService("locate");
}
private _handleCleanSpot() {
this._callVacuumService("clean_spot");
}
private _handleCleanAreas() {
showVacuumCleanAreasView(
this,
this.hass.localize,
this.stateObj!.entity_id
);
}
private _handleFanSpeedChanged(ev: HaDropdownSelectEvent) {
const newVal = ev.detail.item.value;
const oldVal = this.stateObj!.attributes.fan_speed;
if (!newVal || oldVal === newVal) return;
this.hass.callService("vacuum", "set_fan_speed", {
entity_id: this.stateObj!.entity_id,
fan_speed: newVal,
});
}
static styles = css`
:host {
line-height: var(--ha-line-height-normal);
private get _supportsStartPause(): boolean {
if (!this.stateObj) return false;
return (
supportsFeature(this.stateObj, VacuumEntityFeature.START) ||
supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE)
);
}
private get _startPauseIcon(): string {
if (!this.stateObj) return mdiPlay;
// Legacy mode
if (
!supportsFeature(this.stateObj, VacuumEntityFeature.STATE) &&
!supportsFeature(this.stateObj, VacuumEntityFeature.START)
) {
return mdiPlayPause;
}
.status-subtitle {
color: var(--secondary-text-color);
return isCleaning(this.stateObj) &&
supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE)
? mdiPause
: mdiPlay;
}
private get _startPauseLabel(): string {
if (!this.stateObj || !this.hass) return "";
// Legacy mode
if (
!supportsFeature(this.stateObj, VacuumEntityFeature.STATE) &&
!supportsFeature(this.stateObj, VacuumEntityFeature.START)
) {
return this.hass.localize(
"ui.dialogs.more_info_control.vacuum.start_pause"
);
}
.flex-horizontal {
display: flex;
flex-direction: row;
justify-content: space-between;
return isCleaning(this.stateObj) &&
supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE)
? this.hass.localize("ui.dialogs.more_info_control.vacuum.pause")
: this.hass.localize("ui.dialogs.more_info_control.vacuum.start");
}
private get _startPauseDisabled(): boolean {
if (!this.stateObj) return true;
if (this.stateObj.state === UNAVAILABLE) return true;
// Legacy mode - never disabled
if (
!supportsFeature(this.stateObj, VacuumEntityFeature.STATE) &&
!supportsFeature(this.stateObj, VacuumEntityFeature.START)
) {
return false;
}
.space-around {
justify-content: space-around;
// If cleaning, pause is always available
if (isCleaning(this.stateObj)) return false;
return !canStart(this.stateObj);
}
protected render() {
if (!this.stateObj) {
return nothing;
}
`;
const stateObj = this.stateObj;
const isUnavailable = stateObj.state === UNAVAILABLE;
const supportsStop = supportsFeature(stateObj, VacuumEntityFeature.STOP);
const supportsReturnHome = supportsFeature(
stateObj,
VacuumEntityFeature.RETURN_HOME
);
const supportsLocate = supportsFeature(
stateObj,
VacuumEntityFeature.LOCATE
);
const supportsCleanSpot = supportsFeature(
stateObj,
VacuumEntityFeature.CLEAN_SPOT
);
const supportsCleanArea = supportsFeature(
stateObj,
VacuumEntityFeature.CLEAN_AREA
);
const supportsFanSpeed = supportsFeature(
stateObj,
VacuumEntityFeature.FAN_SPEED
);
const hasAnyCommand =
this._supportsStartPause ||
supportsStop ||
supportsReturnHome ||
supportsLocate ||
supportsCleanSpot ||
supportsCleanArea;
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateOverride=${this._stateOverride}
>
${this._renderBattery()}
</ha-more-info-state-header>
<div class="controls">
<ha-state-control-vacuum-status
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-vacuum-status>
${hasAnyCommand
? html`
<div class="buttons">
<ha-control-button-group>
${this._supportsStartPause
? html`
<ha-control-button
.label=${this._startPauseLabel}
@click=${this._handleStartPause}
.disabled=${this._startPauseDisabled}
>
<ha-svg-icon
.path=${this._startPauseIcon}
></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsStop
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.stop"
)}
@click=${this._handleStop}
.disabled=${isUnavailable || !canStop(stateObj)}
>
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsReturnHome
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.return_home"
)}
@click=${this._handleReturnHome}
.disabled=${isUnavailable || !canReturnHome(stateObj)}
>
<ha-svg-icon
.path=${mdiHomeImportOutline}
></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsLocate
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.locate"
)}
@click=${this._handleLocate}
.disabled=${isUnavailable}
>
<ha-svg-icon .path=${mdiMapMarker}></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsCleanSpot
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.clean_spot"
)}
@click=${this._handleCleanSpot}
.disabled=${isUnavailable}
>
<ha-svg-icon .path=${mdiTargetVariant}></ha-svg-icon>
</ha-control-button>
`
: nothing}
</ha-control-button-group>
</div>
`
: nothing}
</div>
${(supportsFanSpeed && stateObj.attributes.fan_speed_list) ||
supportsCleanArea
? html`
<ha-more-info-control-select-container>
${supportsFanSpeed && stateObj.attributes.fan_speed_list
? html`
<ha-control-select-menu
.hass=${this.hass}
.label=${this.hass.formatEntityAttributeName(
stateObj,
"fan_speed"
)}
.value=${stateObj.attributes.fan_speed}
.disabled=${isUnavailable}
@wa-select=${this._handleFanSpeedChanged}
.options=${stateObj.attributes.fan_speed_list.map(
(mode) => ({
value: mode,
label: this.hass.formatEntityAttributeValue(
stateObj,
"fan_speed",
mode
),
})
)}
>
<ha-svg-icon slot="icon" .path=${mdiFan}></ha-svg-icon>
</ha-control-select-menu>
`
: nothing}
${supportsCleanArea
? html`
<button
class="clean-areas-button"
?disabled=${isUnavailable}
@click=${this._handleCleanAreas}
>
<div class="icon">
<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>
</div>
<div class="content">
<p class="label">
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.cleaning"
)}
</p>
<p class="value">
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.by_area"
)}
</p>
</div>
<div class="icon">
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
</div>
</button>
`
: nothing}
</ha-more-info-control-select-container>
`
: nothing}
`;
}
static get styles(): CSSResultGroup {
return [
moreInfoControlStyle,
css`
.battery {
display: inline-flex;
align-items: center;
gap: var(--ha-space-1);
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
--mdc-icon-size: 18px;
}
.battery span {
height: var(--mdc-icon-size);
line-height: var(--mdc-icon-size);
}
ha-state-control-vacuum-status {
margin-bottom: var(--ha-space-4);
}
ha-control-button-group {
--control-button-group-thickness: 48px;
justify-content: center;
}
ha-control-button-group ha-control-button {
flex: 0 0 auto;
width: var(--control-button-group-thickness);
--control-button-border-radius: var(--ha-border-radius-lg);
}
.clean-areas-button {
--mdc-icon-size: 20px;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
width: auto;
max-width: 100%;
height: 48px;
padding: 6px 10px;
border: none;
border-radius: var(--ha-border-radius-lg);
background: none;
color: var(--primary-text-color);
font-family: inherit;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: 1.4;
letter-spacing: 0.25px;
text-align: left;
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
z-index: 0;
transition: box-shadow 180ms ease-in-out;
}
.clean-areas-button::before {
content: "";
position: absolute;
inset: 0;
background-color: var(--disabled-color);
opacity: 0.2;
transition:
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.clean-areas-button:hover::before {
background-color: var(--ha-color-on-neutral-quiet);
}
.clean-areas-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--secondary-text-color);
}
.clean-areas-button[disabled] {
cursor: not-allowed;
color: var(--disabled-color);
}
.clean-areas-button .icon {
display: flex;
--mdc-icon-size: 20px;
}
.clean-areas-button .content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex: 1;
min-width: 0;
overflow: hidden;
}
.clean-areas-button .content p {
margin: 0;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.clean-areas-button .label {
font-size: var(--ha-font-size-s);
letter-spacing: 0.4px;
}
.clean-areas-button .value {
font-size: var(--ha-font-size-m);
}
`,
];
}
}
declare global {

View File

@@ -2,6 +2,7 @@ import { mdiEye, mdiGauge, mdiWaterPercent, mdiWeatherWindy } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { DragScrollController } from "../../../common/controllers/drag-scroll-controller";
import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
@@ -46,9 +47,9 @@ class MoreInfoWeather extends LitElement {
@state() private _subscribed?: Promise<() => void>;
// @ts-ignore
private _dragScrollController = new DragScrollController(this, {
selector: ".forecast",
enabled: false,
});
private _unsubscribeForecastEvents() {
@@ -128,6 +129,20 @@ class MoreInfoWeather extends LitElement {
}
}
protected updated(_changedProps: PropertyValues): void {
super.updated(_changedProps);
if (!this.stateObj) {
this._dragScrollController.enabled = false;
return;
}
this._dragScrollController.enabled = Boolean(
getForecast(this.stateObj.attributes, this._forecastEvent)?.forecast
?.length
);
}
private _supportedForecasts = memoizeOne((stateObj: WeatherEntity) =>
getSupportedForecastTypes(stateObj)
);
@@ -336,7 +351,12 @@ class MoreInfoWeather extends LitElement {
)}
</ha-tab-group>`
: nothing}
<div class="forecast">
<div
class=${classMap({
forecast: true,
dragging: this._dragScrollController.scrolling,
})}
>
${forecast?.length
? this._groupForecastByDay(forecast).map((dayForecast) => {
const showDayHeader = hourly || dayNight;
@@ -591,6 +611,15 @@ class MoreInfoWeather extends LitElement {
transparent 100%
);
user-select: none;
cursor: grab;
}
.forecast.dragging {
cursor: grabbing;
}
.forecast.dragging * {
pointer-events: none;
}
.forecast-day {

View File

@@ -11,7 +11,6 @@ import {
coverSupportsAnyPosition,
coverSupportsPosition,
coverSupportsTiltPosition,
normalizeCoverFavoritePositions,
} from "../../data/cover";
import type {
ExtEntityRegistryEntry,
@@ -34,9 +33,9 @@ import {
import type { ValveEntity } from "../../data/valve";
import {
DEFAULT_VALVE_FAVORITE_POSITIONS,
normalizeValveFavoritePositions,
valveSupportsPosition,
} from "../../data/valve";
import { normalizeFavoritePositions } from "../../data/favorite_positions";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import { showFormDialog } from "../form/show-form-dialog";
@@ -226,7 +225,7 @@ const coverFavoritesHandler = createNumericFavoritesDialogHandler<CoverEntity>({
supports: coverSupportsPosition,
getStoredFavorites: (entry) => entry.options?.cover?.favorite_positions,
getFavorites: (entry) =>
normalizeCoverFavoritePositions(
normalizeFavoritePositions(
entry.options?.cover?.favorite_positions ??
DEFAULT_COVER_FAVORITE_POSITIONS
),
@@ -237,7 +236,7 @@ const coverFavoritesHandler = createNumericFavoritesDialogHandler<CoverEntity>({
getStoredFavorites: (entry) =>
entry.options?.cover?.favorite_tilt_positions,
getFavorites: (entry) =>
normalizeCoverFavoritePositions(
normalizeFavoritePositions(
entry.options?.cover?.favorite_tilt_positions ??
DEFAULT_COVER_FAVORITE_POSITIONS
),
@@ -311,7 +310,7 @@ const valveFavoritesHandler = createNumericFavoritesDialogHandler<ValveEntity>({
supports: valveSupportsPosition,
getStoredFavorites: (entry) => entry.options?.valve?.favorite_positions,
getFavorites: (entry) =>
normalizeValveFavoritePositions(
normalizeFavoritePositions(
entry.options?.valve?.favorite_positions ??
DEFAULT_VALVE_FAVORITE_POSITIONS
),

View File

@@ -23,6 +23,7 @@ import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
@@ -101,14 +102,15 @@ interface ChildView {
viewTitle?: string;
viewImport?: () => Promise<unknown>;
viewParams?: any;
viewHeaderTag?: string;
viewHeaderImport?: () => Promise<unknown>;
}
declare global {
interface HASSDomEvents {
"show-child-view": ChildView;
}
interface HASSDomEvents {
"toggle-edit-mode": boolean;
"close-child-view": undefined;
}
}
@@ -138,7 +140,11 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@state() private _childView?: ChildView;
@state() private _childViewStack: ChildView[] = [];
private get _childView(): ChildView | undefined {
return this._childViewStack[this._childViewStack.length - 1];
}
@state() private _entry?: ExtEntityRegistryEntry | null;
@@ -168,7 +174,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._data = params.data;
this._currView = view;
this._initialView = view;
this._childView = undefined;
this._childViewStack = [];
this._infoEditMode = false;
this._detailsYamlMode = false;
@@ -208,7 +214,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._detailsYamlMode = false;
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childView = undefined;
this._childViewStack = [];
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -279,7 +285,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
if (dialog) {
fireEvent(dialog as HTMLElement, "dialog-set-fullscreen", false);
}
this._childView = undefined;
this._childViewStack = this._childViewStack.slice(0, -1);
this._detailsYamlMode = false;
return;
}
@@ -315,11 +321,17 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
private _showChildView(ev: CustomEvent): void {
const view = ev.detail as ChildView;
this._pushChildView(ev.detail as ChildView);
}
private _pushChildView(view: ChildView): void {
if (view.viewImport) {
view.viewImport();
}
this._childView = view;
if (view.viewHeaderImport) {
view.viewHeaderImport();
}
this._childViewStack = [...this._childViewStack, view];
}
private _goToDevice(): void {
@@ -587,6 +599,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
.width=${this._fill ? "full" : this.large ? "large" : "medium"}
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@show-child-view=${this._showChildView}
.preventScrimClose=${this._currView === "settings" ||
!this._isEscapeEnabled}
flexcontent
@@ -789,17 +802,12 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@click=${this._toggleDetailsYamlMode}
></ha-icon-button>
`
: this._childView?.viewTag === "ha-more-info-details"
? html`
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.toggle_yaml_mode"
)}
.path=${mdiCodeBraces}
@click=${this._toggleDetailsYamlMode}
></ha-icon-button>
`
: this._childView?.viewHeaderTag
? dynamicElement(this._childView.viewHeaderTag, {
slot: "headerActionItems",
hass: this.hass,
params: this._childView.viewParams,
})
: nothing}
<div
class=${classMap({
@@ -813,7 +821,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
<div
class="content ha-scrollbar"
tabindex="-1"
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
@@ -822,24 +829,11 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._childView
? html`
<div class="child-view">
${this._childView.viewTag ===
"ha-more-info-view-voice-assistants"
? html`
<ha-more-info-view-voice-assistants
.hass=${this.hass}
.entry=${this._entry!}
.params=${this._childView.viewParams}
></ha-more-info-view-voice-assistants>
`
: this._childView.viewTag ===
"ha-more-info-view-vacuum-segment-mapping"
? html`
<ha-more-info-view-vacuum-segment-mapping
.hass=${this.hass}
.params=${this._childView.viewParams}
></ha-more-info-view-vacuum-segment-mapping>
`
: nothing}
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: this._currView === "info"
@@ -908,6 +902,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this.addEventListener("close-dialog", () => this.closeDialog());
this.addEventListener("close-child-view", () => this._goBack());
this._loadNumericDeviceClasses();
}
@@ -965,7 +960,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childView = undefined;
this._childViewStack = [];
this._loadEntityRegistryEntry();
}

View File

@@ -7,6 +7,7 @@ import { computeDomain } from "../../common/entity/compute_domain";
import { createSearchParam } from "../../common/url/search-params";
import "../../components/chart/state-history-charts";
import "../../components/chart/statistics-chart";
import "../../components/ha-alert";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
@@ -48,7 +49,7 @@ export class MoreInfoHistory extends LitElement {
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
private _error?: string;
@state() private _error?: { code: string; message: string };
private _metadata?: Record<string, StatisticsMetaData>;
@@ -80,7 +81,10 @@ export class MoreInfoHistory extends LitElement {
>`}
</div>
${this._error
? html`<div class="errors">${this._error}</div>`
? html`<ha-alert alert-type="error">
${this.hass.localize("ui.components.history_charts.error")}:
${this._error.message || this._error.code}
</ha-alert>`
: this._statistics
? html`<statistics-chart
.hass=${this.hass}
@@ -123,6 +127,20 @@ export class MoreInfoHistory extends LitElement {
this._showMoreHref = `/history?${createSearchParam(params)}`;
this._getStateHistory();
} else if (
changedProps.has("hass") &&
this.entityId &&
!this._subscribed &&
!this._error
) {
// Retry when components become available after backend restart
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
oldHass &&
oldHass.config.components !== this.hass.config.components
) {
this._getStateHistory();
}
}
}
@@ -141,7 +159,7 @@ export class MoreInfoHistory extends LitElement {
private _unsubscribeHistory() {
clearInterval(this._interval);
if (this._subscribed) {
this._subscribed.then((unsub) => unsub?.());
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
this._subscribed = undefined;
}
}
@@ -228,8 +246,27 @@ export class MoreInfoHistory extends LitElement {
this._unsubscribeHistory();
}
const { numeric_device_classes: sensorNumericDeviceClasses } =
await getSensorNumericDeviceClasses(this.hass);
// Mark as subscribing before the await to prevent re-entrant calls
const sentinel = Promise.resolve(undefined) as NonNullable<
typeof this._subscribed
>;
this._subscribed = sentinel;
let sensorNumericDeviceClasses: string[];
try {
({ numeric_device_classes: sensorNumericDeviceClasses } =
await getSensorNumericDeviceClasses(this.hass));
} catch (_err) {
if (this._subscribed === sentinel) {
this._subscribed = undefined;
}
return;
}
// Bail out if a newer call replaced our sentinel while we were awaiting
if (this._subscribed !== sentinel) {
return;
}
this._subscribed = subscribeHistoryStatesTimeWindow(
this.hass!,

View File

@@ -233,7 +233,6 @@ export class QuickBar extends LitElement {
return html`
<ha-adaptive-dialog
without-header
allow-mode-change
flexcontent
.hass=${this.hass}
aria-label=${this.hass.localize("ui.dialogs.quick-bar.title")}

View File

@@ -18,6 +18,7 @@ export interface ShowToastParams {
action?: ToastActionParams;
duration?: number;
dismissable?: boolean;
bottomOffset?: number;
}
export interface ToastActionParams {
@@ -89,6 +90,7 @@ class NotificationManager extends LitElement {
)
: this._parameters.message}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${this._parameters?.action

View File

@@ -178,7 +178,7 @@ class SupervisorAppConfig extends LitElement {
path: string[]
): Selector | null {
if (entry.type === "select") {
return { select: { options: entry.options } };
return { select: { options: entry.options, multiple: entry.multiple } };
}
if (entry.type === "string") {
return entry.multiple

View File

@@ -77,7 +77,7 @@ import {
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
@@ -248,10 +248,14 @@ export default class HaAutomationActionRow extends LitElement {
"target" in
(this.hass.services?.[computeDomain(action)]?.[
computeObjectId(action)
] || {});
] || {}) &&
// special case for reload config entry as it has an optional target but mainly uses entry_id
((this.action as ServiceAction).action !==
"homeassistant.reload_config_entry" ||
!(this.action as ServiceAction).data?.entry_id);
const target = actionHasTarget
? (this.action as ServiceAction).target
? this._extractTargets(this.action as ServiceAction)
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
@@ -591,6 +595,18 @@ export default class HaAutomationActionRow extends LitElement {
`;
}
private _extractTargets(action: ServiceAction): HassServiceTarget {
if (action.target) {
return action.target;
}
// legacy support for entity_id
if (action.entity_id) {
return { entity_id: action.entity_id };
}
return {};
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget, targetRequired = false) =>
html`<ha-automation-row-targets
@@ -688,7 +704,7 @@ export default class HaAutomationActionRow extends LitElement {
return;
}
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.actions.run_action_success"
),
@@ -701,7 +717,7 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "close-sidebar");
}
showToast(this, {
showEditorToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
@@ -769,7 +785,7 @@ export default class HaAutomationActionRow extends LitElement {
private _copyAction = () => {
this._setClipboard();
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.actions.copied_to_clipboard"
),
@@ -783,7 +799,7 @@ export default class HaAutomationActionRow extends LitElement {
if (this._selected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.actions.cut_to_clipboard"
),

View File

@@ -16,6 +16,7 @@ import {
} from "../../../../data/action";
import { getValueFromDynamic, isDynamic } from "../../../../data/automation";
import type { Action } from "../../../../data/script";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
@@ -31,6 +32,8 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
) {
@property({ type: Boolean }) public root = false;
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@property({ attribute: false }) public actions!: Action[];
@property({ attribute: false }) public highlightedActions?: Action[];
@@ -192,6 +195,9 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
type: "action",
add: this._addAction,
clipboardItem: getAutomationActionType(this._clipboard?.action),
clipboardPasteToastBottomOffset: this.editorDirty
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
: undefined,
});
}

View File

@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import { assert } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-service-control";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
@@ -72,6 +73,12 @@ export class HaServiceAction extends LitElement implements ActionElement {
const [domain, service] = this._action.action
? this._action.action.split(".", 2)
: [undefined, undefined];
const optionalResponse =
domain && service
? !!this.hass.services[domain]?.[service]?.response?.optional
: false;
return html`
<ha-service-control
.narrow=${this.narrow}
@@ -84,22 +91,29 @@ export class HaServiceAction extends LitElement implements ActionElement {
></ha-service-control>
${domain && service && this.hass.services[domain]?.[service]?.response
? html`<ha-settings-row .narrow=${this.narrow}>
${this.hass.services[domain][service].response!.optional
${optionalResponse
? html`<ha-checkbox
.checked=${this._action.response_variable ||
.checked=${!!this._action.response_variable ||
this._responseChecked}
.disabled=${this.disabled}
@change=${this._responseCheckboxChanged}
slot="prefix"
></ha-checkbox>`
: html`<div slot="prefix" class="checkbox-spacer"></div>`}
<span slot="heading"
<span
slot="heading"
class=${optionalResponse ? "clickable" : ""}
@click=${optionalResponse ? this._toggleCheckbox : undefined}
>${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.service.response_variable"
)}</span
>
<span slot="description">
${this.hass.services[domain][service].response!.optional
<span
slot="description"
class=${optionalResponse ? "clickable" : ""}
@click=${optionalResponse ? this._toggleCheckbox : undefined}
>
${optionalResponse
? this.hass.localize(
"ui.panel.config.automation.editor.actions.type.service.has_optional_response"
)
@@ -109,10 +123,9 @@ export class HaServiceAction extends LitElement implements ActionElement {
</span>
<ha-input
.value=${this._action.response_variable || ""}
.required=${!this.hass.services[domain][service].response!
.optional}
.required=${!optionalResponse}
.disabled=${this.disabled ||
(this.hass.services[domain][service].response!.optional &&
(optionalResponse &&
!this._action.response_variable &&
!this._responseChecked)}
@change=${this._responseVariableChanged}
@@ -156,6 +169,13 @@ export class HaServiceAction extends LitElement implements ActionElement {
fireEvent(this, "value-changed", { value });
}
private _toggleCheckbox(ev: Event) {
const checkbox = (
ev.currentTarget as HTMLElement
)?.parentElement?.querySelector("ha-checkbox");
checkbox?.click();
}
private _responseCheckboxChanged(ev) {
this._responseChecked = ev.target.checked;
if (!this._responseChecked) {
@@ -182,14 +202,12 @@ export class HaServiceAction extends LitElement implements ActionElement {
1px solid var(--divider-color)
);
}
ha-checkbox {
margin-left: -16px;
margin-inline-start: -16px;
margin-inline-end: initial;
}
.checkbox-spacer {
width: 32px;
}
.clickable {
cursor: pointer;
}
`;
}

View File

@@ -2122,6 +2122,12 @@ class DialogAddAutomationElement
),
}
),
dismissable: true,
...(this._params.clipboardPasteToastBottomOffset != null
? {
bottomOffset: this._params.clipboardPasteToastBottomOffset,
}
: {}),
});
this.closeDialog();
}

View File

@@ -321,6 +321,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
: undefined
)
);
return html`<ha-section-title
>${this._i18n.localize(
"ui.panel.config.automation.editor.home"
@@ -868,7 +869,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
this._floorAreas.forEach((floor) => {
this._entries[floor.id || `floor${TARGET_SEPARATOR}`] = {
open: false,
// auto expand if only one floor is present
open: this._floorAreas.length === 1,
areas: {},
};

View File

@@ -60,7 +60,7 @@ import {
} from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
@@ -531,7 +531,7 @@ export default class HaAutomationConditionRow extends LitElement {
fireEvent(this, "close-sidebar");
}
showToast(this, {
showEditorToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {
@@ -665,7 +665,7 @@ export default class HaAutomationConditionRow extends LitElement {
private _copyCondition = () => {
this._setClipboard();
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.copied_to_clipboard"
),
@@ -679,7 +679,7 @@ export default class HaAutomationConditionRow extends LitElement {
if (this._selected) {
fireEvent(this, "close-sidebar");
}
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.cut_to_clipboard"
),

View File

@@ -25,6 +25,7 @@ import {
} from "../../../../data/condition";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
@@ -44,6 +45,8 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
@property({ type: Boolean }) public root = false;
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@queryAll("ha-automation-condition-row")
@@ -280,6 +283,9 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
type: "condition",
add: this._addCondition,
clipboardItem: this._clipboard?.condition?.condition,
clipboardPasteToastBottomOffset: this.editorDirty
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
: undefined,
});
}

View File

@@ -248,19 +248,27 @@ export class HaPlatformCondition extends LitElement {
: html`<ha-checkbox
.key=${fieldName}
.checked=${this._checkedKeys.has(fieldName) ||
(this.condition?.options &&
(!!this.condition?.options &&
this.condition.options[fieldName] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
<span
slot="heading"
class=${showOptional ? "clickable" : ""}
@click=${showOptional ? this._toggleCheckbox : undefined}
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || fieldName}</span
>
${description
? html`<span slot="description">${description}</span>`
? html`<span
class=${showOptional ? "clickable" : ""}
@click=${showOptional ? this._toggleCheckbox : undefined}
slot="description"
>${description}</span
>`
: nothing}
<ha-selector
.disabled=${this.disabled ||
@@ -347,6 +355,13 @@ export class HaPlatformCondition extends LitElement {
});
}
private _toggleCheckbox(ev: Event) {
const checkbox = (
ev.currentTarget as HTMLElement
)?.parentElement?.querySelector("ha-checkbox");
checkbox?.click();
}
private _checkboxChanged(ev) {
const checked = ev.currentTarget.checked;
const key = ev.currentTarget.key;
@@ -499,11 +514,6 @@ export class HaPlatformCondition extends LitElement {
.checkbox-spacer {
width: 32px;
}
ha-checkbox {
margin-left: calc(var(--ha-space-4) * -1);
margin-inline-start: calc(var(--ha-space-4) * -1);
margin-inline-end: initial;
}
.help-icon {
color: var(--secondary-text-color);
}
@@ -518,6 +528,9 @@ export class HaPlatformCondition extends LitElement {
.description p {
direction: ltr;
}
.clickable {
cursor: pointer;
}
`;
}

View File

@@ -0,0 +1,32 @@
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
import type { ShowToastParams } from "../../../managers/notification-manager";
import { showToast } from "../../../util/toast";
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
if (
el.localName === "ha-automation-editor" ||
el.localName === "ha-script-editor"
) {
return Boolean((el as { dirty?: boolean }).dirty);
}
const holder = closestWithProperty(el, "dirty", false) as
| (HTMLElement & { dirty?: boolean })
| null;
return Boolean(holder?.dirty);
}
export function showEditorToast(
el: HTMLElement,
params: ShowToastParams
): void {
const offset = editorSaveFabVisibleFrom(el)
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
: undefined;
showToast(el, {
...params,
...(offset !== undefined ? { bottomOffset: offset } : {}),
dismissable: true,
});
}

View File

@@ -71,7 +71,7 @@ import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries, ValueChangedEvent } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import { showEditorToast } from "./editor-toast";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
import { showAutomationSaveDialog } from "./automation-save-dialog/show-dialog-automation-save";
@@ -914,7 +914,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _handleSaveAutomation(): Promise<void> {
if (this.yamlErrors) {
showToast(this, {
showEditorToast(this, {
message: this.yamlErrors,
});
return;
@@ -997,7 +997,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.dirty = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showToast(this, {
showEditorToast(this, {
message: errors.body?.message || errors.error || errors.body,
});
throw errors;

View File

@@ -46,6 +46,7 @@ import type {
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-button";
import "../../../components/ha-checkbox";
import "../../../components/ha-dropdown";
import type {
HaDropdown,
@@ -1334,7 +1335,6 @@ ${rejected
slot="icon"
.checked=${selected}
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label .color=${label.color} .description=${label.description}>
${label.icon

View File

@@ -32,7 +32,7 @@ import {
normalizeAutomationConfig,
} from "../../../data/automation";
import { getActionType, type Action } from "../../../data/script";
import { showToast } from "../../../util/toast";
import { showEditorToast } from "./editor-toast";
import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
@@ -101,6 +101,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
.highlightedTriggers=${this.pastedConfig?.triggers}
@value-changed=${this._triggerChanged}
.hass=${this.hass}
.editorDirty=${this.dirty}
.disabled=${this.disabled || this.saving}
.narrow=${this.narrow}
@open-sidebar=${this.openSidebar}
@@ -136,6 +137,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
.highlightedConditions=${this.pastedConfig?.conditions}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.editorDirty=${this.dirty}
.disabled=${this.disabled || this.saving}
.narrow=${this.narrow}
@open-sidebar=${this.openSidebar}
@@ -170,6 +172,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
@request-close-sidebar=${this.triggerCloseSidebar}
@close-sidebar=${this.handleCloseSidebar}
.hass=${this.hass}
.editorDirty=${this.dirty}
.narrow=${this.narrow}
.disabled=${this.disabled || this.saving}
root
@@ -223,7 +226,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
try {
loaded = load(paste);
} catch (_err: any) {
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_invalid_yaml"
),
@@ -297,7 +300,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
try {
assert(normalized, automationConfigStruct);
} catch (_err: any) {
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_invalid_config"
),
@@ -391,7 +394,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
}
protected showPastedToastWithUndo() {
showToast(this, {
showEditorToast(this, {
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_toast_message"
),

View File

@@ -37,7 +37,7 @@ import type { Action, Option } from "../../../../data/script";
import { showPromptDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import { showEditorToast } from "../editor-toast";
import "../action/ha-automation-action";
import type HaAutomationAction from "../action/ha-automation-action";
import "../condition/ha-automation-condition";
@@ -385,7 +385,7 @@ export default class HaAutomationOptionRow extends LitElement {
fireEvent(this, "close-sidebar");
}
showToast(this, {
showEditorToast(this, {
message: this.hass.localize("ui.common.successfully_deleted"),
duration: 4000,
action: {

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