Compare commits

..

165 Commits

Author SHA1 Message Date
Paul Bottein
5e53dbfc16 Add entity-first card picker for dashboard 2026-04-21 14:46:23 +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
Wendelin
9cce20bad1 Enhance sun condition automation: add 'between' option and improve duration formatting (#51502) 2026-04-13 11:58:20 +02:00
Aidan Timson
c9ad84b234 Register custom dashboard strategies (#51310)
* Allow custom dashboard strategies to register for the add dashboard dialog

Adds a window.customDashboardStrategies registration mechanism (mirroring
window.customCards) so third-party strategies can appear in the new dashboard
picker. Only dashboard-level strategies are surfaced; view and section
strategies are excluded. Custom strategies appear in their own section and
are included in search results.

https://claude.ai/code/session_019MXBdWUQrFQfH54QVjbq8y

* Rename custom dashboards heading to community dashboards

https://claude.ai/code/session_019MXBdWUQrFQfH54QVjbq8y

* Consolidate into a single customStrategies registry for all strategy types

The window.customStrategies array now accepts entries with a strategyType
field (dashboard, view, or section). The dialog filters for dashboard
strategies. This allows future use of the same registry for view and
section strategy registration.

https://claude.ai/code/session_019MXBdWUQrFQfH54QVjbq8y

* Space tokens

* Space tokens

* Add support for images

* Allow single image from custom strategy

* Load needed resources for new dashboard dialog

* Preload custom strategies

* Catch potential error

* Reset if error

* Improve typing

* Cache module resources to avoid duplicate loads

* Revert "Cache module resources to avoid duplicate loads"

This reverts commit 87bbcc0451.

* Set a max height for dashboard images (match max height of current core images)

* Remove image support

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 11:57:59 +02:00
Wendelin
cb89b8aea8 Use wa-tag for target picker chips (#51482) 2026-04-13 11:57:46 +02:00
dependabot[bot]
a5f4885d95 Bump home-assistant/actions from 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 to f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b (#51535)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 to f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](5752577ea7...f6f29a7ee3)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:08:45 +02:00
Aidan Timson
e2e114cb4e Add shared editor for all more info hints, add lights (#51542)
* Add more info redirect editor for light favorites

* Merge all hint feature editors to one shared component

* Fix
2026-04-13 11:07:55 +02:00
dependabot[bot]
4a0284455d Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#51537)
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](ed0c53931b...cef221092e)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 11:06:10 +02:00
JamesFromIT
d220eba9f7 Make history page title a semantic heading (#51527) 2026-04-13 08:39:50 +00:00
Paul Bottein
2edb0325aa Improve box shadow design tokens with multi-layer shadows (#51378) 2026-04-13 10:36:17 +02:00
dependabot[bot]
2e1582a9c1 Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#51538)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:36:01 +01:00
dependabot[bot]
006cdf088a Bump release-drafter/release-drafter from 7.1.1 to 7.2.0 (#51536)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:35:46 +01:00
Wendelin
d9b0bf21c0 Fix register admin quick search shortcuts (#51540) 2026-04-13 08:56:03 +01:00
JamesFromIT
7df059b4cf Make media management dialog heading a semantic heading element (#51522) 2026-04-13 06:41:36 +00:00
JamesFromIT
4cfc0dd6c3 Make media browser tab title a semantic heading element (#51528) 2026-04-13 06:36:32 +00:00
Carlos Aguilar
fb9f182dcc Fix: Center more-info-media_player component volume buttons (#51517)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-13 06:33:05 +00:00
renovate[bot]
880b226d10 Update dependency sinon to v21.1.0 (#51529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 07:04:53 +02:00
renovate[bot]
031e6ea789 Update dependency prettier to v3.8.2 (#51534)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 07:04:15 +02:00
pcan08
d025a842c4 Use header-subtitle in dialog-edit-home (#51525)
Replace the custom `<p class="description">` element and its associated
CSS with the built-in `headerSubtitle` property on `ha-dialog`.
2026-04-12 11:27:11 +02:00
Wendelin
775f145c9f Add ha-progress-bar highlight style variables (#51489)
Enhance ha-progress-bar styles and properties for improved customization
2026-04-12 09:51:01 +02:00
Paulus Schoutsen
f9caf5365e Hide internal panels from navigation picker (#51497)
* Hide _my_redirect and notfound panels from navigation picker

These internal panels are not useful navigation targets and should not
appear in the "Other routes" section of the navigation picker.

https://claude.ai/code/session_01DT6YNh9gjLpTztxA6z79w5

* Address review: use panel constants and move to module level

- Add MY_REDIRECT_PANEL constant to src/data/panel.ts
- Use NOT_FOUND_PANEL and MY_REDIRECT_PANEL instead of string literals
- Move HIDDEN_PANELS to module level since it doesn't need recreation

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

* Add SYSTEM_PANELS constant and use in navigation picker and quick bar

- Add APP_PANEL constant and SYSTEM_PANELS array to data/panel.ts
- Use SYSTEM_PANELS in ha-navigation-picker.ts and quick_bar.ts
- Remove obsolete hassio panel filter from quick bar (no longer exists)
- Also hides the app panel from navigation picker

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 09:49:15 +02:00
renovate[bot]
b1419b7761 Update vitest monorepo to v4.1.4 (#51524)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 07:45:00 +00:00
Paulus Schoutsen
be0a673d4e Add error handling for backup creation failures (#51520)
* Show error details when backup creation fails

When generateBackup or generateBackupWithAutomaticSettings raises an
error (e.g., not enough free space), the error details are now shown
to the user in an alert dialog instead of only being logged to the
console.

https://claude.ai/code/session_01XWxeS4ZxxZfnt8h2pLsSMn

* Consolidate duplicate try-catch into single block in _newBackup

https://claude.ai/code/session_01XWxeS4ZxxZfnt8h2pLsSMn

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 09:39:10 +02:00
Aidan Timson
8e31316692 Add link to UX design in issue template (#51503) 2026-04-12 09:38:01 +02:00
renovate[bot]
f9db26166f Update dependency typescript-eslint to v8.58.1 (#51514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 11:00:39 +02:00
Wendelin
7ceba8d231 Add context groups (#51471) 2026-04-10 16:03:34 +02:00
renovate[bot]
2a0b4c8f18 Update vitest monorepo to v4.1.3 (#51505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 10:52:27 +00:00
Aidan Timson
6c762e0105 Ignore local opencode directory (#51504) 2026-04-10 12:44:48 +02:00
renovate[bot]
4ceb4c3c2c Update dependency marked to v18 (#51499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 08:58:35 +02:00
renovate[bot]
cebdb46989 Update dependency jsdom to v29.0.2 (#51498)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 08:42:18 +02:00
Paulus Schoutsen
5aeae9ffa5 Fix duplicate "Add custom path" entry in navigation picker (#51496)
The navigation picker's _getItems was adding an "Add custom path" item,
but ha-picker-combo-box already adds one when allowCustomValue is set
and there's a search string. Remove the duplicate from _getItems since
the combo box handles it via the customValueLabel prop.

https://claude.ai/code/session_01NAB8bo1B6HuGFwKZVbvL1S

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-10 08:19:58 +03:00
Aidan Timson
2ce62841cf Settings dashboard repairs and updates design update (#51491)
* Reuse headings for config dashboard repairs and updates

* Keep headings internal to card and remove icons

* Merge headings into components

* Remove extra component for heading

* Use correct back links

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

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-09 20:29:56 +02:00
Timothy
63c9b85e6c Android externalAppV2 (#51446) 2026-04-09 16:34:32 +02:00
Petar Petrov
03ace97a7e Enable zoom and pan on sankey charts (#51488) 2026-04-09 16:32:12 +02:00
renovate[bot]
9edcfaf6b3 Update dependency @lokalise/node-api to v15.7.1 (#51490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 14:53:58 +01:00
Paul Bottein
5cb7fdbfed Add search bar to integration page (#51485)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-09 15:16:01 +02:00
Petar Petrov
5a0e1e89e6 Add click-to-open-more-info to energy and water sankey cards (#51487) 2026-04-09 11:03:28 +00:00
Aidan Timson
5ac6906943 Fix regressions for search inputs with focus scrollable changes (#51484) 2026-04-09 10:51:43 +01:00
pcan08
cf1fb7751f Home dashboard: show hide welcome message header (#51401)
* Allow showing/hiding welcome message on home overview

Add a toggle in the edit overview dialog to show or hide the
"Welcome, [user]" greeting header on the home overview page,
following the same pattern as the existing summary enable/disable.

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

* Move welcome message toggle into its own section in home editor

The welcome message is a greeting header, not a summary card, so it
now lives in a separate "Greeting" section above the "Summaries" section.

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

* rename hidden_welcome_message to hide_welcome_message

* Use ha-form boolean selector for welcome message toggle

Replace manual label/ha-switch markup with ha-form using a boolean
selector for better accessibility and consistency with the rest of
the codebase.

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

* Add helper text to welcome message toggle in home editor

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

* Remove greeting section header

* Extract welcome message schema into a module-level constant

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:53:41 +02:00
Paul Bottein
22f8ee0d79 Refactor integration page to build device/sub-entry tree in parent (#51374)
* Refactor integration page to build device/sub-entry tree in parent

* Avoid concurrency update

* Fix memoize

* Fix device name display
2026-04-09 11:02:56 +03:00
Bram Kragten
9e7d162724 Use fieldname as fallback instead of trigger/condition (#51474) 2026-04-08 14:39:30 +00:00
Bram Kragten
14addf02b8 Remove unused deps (#51473) 2026-04-08 15:38:42 +01:00
renovate[bot]
17bcf59c6a Update dependency browserslist-useragent-regexp to v4.1.4 (#51470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 14:19:40 +01:00
Stefan Agner
0b1aa4a901 Rename "Registries" menu entry to "Registry credentials" (#51469)
Container registries can't be added really, they are part of an image
name. So the menu entry is a bit misleading. This commit renames it to
"Registry credentials" to make it more clear that it's just about
credentials for registries, not about adding registries.
2026-04-08 15:35:35 +03:00
Aidan Timson
aab2304d86 Remove extra "Community:" prefix for add badge dialog (#51465)
Remove extra "Custom:" naming
2026-04-08 10:35:01 +00:00
Aidan Timson
c013f79826 Add links to logs in integration page (#51463)
* Add link to logs in integration page

* Join

* Add link to "Check the logs"

* Add to entry overflow when in error state
2026-04-08 13:34:59 +03:00
renovate[bot]
60236c2fee Update dependency @html-eslint/eslint-plugin to v0.59.0 (#51464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 11:26:42 +01:00
Aidan Timson
20d53a2659 Allow quick search for non-admins, while hiding inaccessible areas (#51456)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-08 09:50:36 +02:00
Mihail Sîrbu
6dbc38386c Add apps info page for non-HAOS installations (#30364)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-08 07:02:47 +00:00
renovate[bot]
ce5a19caa8 Update dependency marked to v17.0.6 (#51460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 08:59:33 +02:00
Wendelin
2cda06e7a6 Introduce ha-progress-bar (#51453)
* Replace mwc-linear-progress with ha-progress-bar

* Update src/panels/lovelace/cards/hui-media-control-card.ts

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

* Remove duplicate import of ha-slider in hui-media-control-card.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-08 09:40:42 +03:00
renovate[bot]
65485ce8c9 Update dependency fuse.js to v7.3.0 (#51457)
* Update dependency fuse.js to v7.3.0

* type fix

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-08 05:28:37 +00:00
GeorgeZ83
b73ae60cea Fix media browser dialog window (#51423)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-07 14:52:16 +00:00
Petar Petrov
cef35c6c23 Center energy dashboard bar charts on period midpoint (#30325) 2026-04-07 16:51:35 +02:00
Petar Petrov
6b9685ec9f Fix time condition summary using "and" instead of "or" for midnight-crossing ranges (#51452) 2026-04-07 16:47:17 +02:00
Petar Petrov
fc9289dc05 Fix incorrect timezone in automation time trigger/condition descriptions (#51454) 2026-04-07 14:09:28 +00:00
Aidan Timson
2a2bca2a61 Handle lazy loaded entity registry when editing scripts from more info (#51438)
* Handle lazy loaded entity registry when editing scripts from more info

* Remove extra check

* Fix type of mixin
2026-04-07 17:07:21 +03:00
Petar Petrov
1eda51ddbc Allow customizing initial map view with latitude, longitude, and zoom (#51444)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 15:07:13 +01:00
Wendelin
22738f6d77 Remove fab (#51448)
* Remove ha-fab replace with ha-button

* Remove import of ha-fab component

* Remove mwc-fab
2026-04-07 15:46:36 +03:00
Petar Petrov
2f73351c35 Fix dialog show animation broken by connectedCallback _open sync (#51450) 2026-04-07 12:18:16 +00:00
Petar Petrov
44b442dc0e Fix toast race condition causing stuck notifications (#51447) 2026-04-07 13:13:18 +01:00
Aidan Timson
916731d0ee Restore custom wording for integrations (#51440) 2026-04-07 13:11:28 +01:00
Aidan Timson
5113594d6b Use script field name if available in row instead of key (#51445)
Use script field name if available in row
2026-04-07 14:46:17 +03:00
Petar Petrov
edd162df68 Use websocket subscription for calendar events (#27906)
* Use websocket subscription for calendar events

Replace polling-based calendar event fetching with real-time websocket subscriptions. This leverages the new subscription API added in core to provide automatic updates when calendar events change, eliminating the need for periodic polling.

The subscription pattern follows the same approach used for todo items, with proper lifecycle management and cleanup.

Related: home-assistant/core#156340
Related: #27565

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix calendar events not loading on initial render

Remove premature subscription attempt in setConfig() that was failing because the date range wasn't available yet. The subscription now properly happens when the view-changed event fires from ha-full-calendar after initial render, which includes the necessary start/end dates.

This ensures calendar events load immediately when the component is first displayed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix calendar subscription data format mismatch

The websocket subscription returns event data with fields named start and end, but the frontend was expecting dtstart and dtend. This caused events to not display because the data wasn't being properly mapped.

Now properly transform the subscription response format:
- Subscription format: start/end/summary/description
- Internal format: dtstart/dtend/summary/description

This ensures both initial event loading and real-time updates work correctly.

* Address PR review comments

Fixes based on Copilot review feedback:

1. **Fixed race conditions**: Made _unsubscribeAll() async and await it
   before creating new subscriptions to prevent old subscription events
   from updating UI after new subscriptions are created.

2. **Added error handling**: All unsubscribe operations now catch errors
   to handle cases where subscriptions may have already been closed.

3. **Fixed type safety**: Replaced 'any' type with proper
   CalendarEventSubscriptionData type and added interface definition
   for subscription response data structure.

4. **Improved error tracking**: Calendar card now accumulates errors from
   multiple calendars instead of only showing the last error.

5. **Prevented duplicate subscriptions**: Added checks to unsubscribe
   existing subscriptions before creating new ones in both
   _subscribeCalendarEvents and _requestSelected.

6. **Fixed null handling**: Properly convert null values to undefined
   for CalendarEventData fields to match expected types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Extract event normalization to shared utility function

Reduced code duplication by extracting the calendar event normalization
logic from both hui-calendar-card.ts and ha-panel-calendar.ts into a
shared utility function in calendar.ts.

The normalizeSubscriptionEventData() function handles the conversion
from subscription format (start/end) to internal format (dtstart/dtend)
in a single, reusable location.

This improves maintainability by ensuring consistent event normalization
across all calendar components and reduces the risk of divergence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Address additional review comments

Fixed remaining issues from code review:

1. **Added @state decorator to _errorCalendars**: Ensures proper reactivity
   in calendar card when errors occur or are cleared, triggering UI updates.

2. **Fixed error accumulation in panel calendar**: Panel now properly
   accumulates errors from multiple calendars similar to the card
   implementation, preventing previously failed calendars from being
   hidden when new errors occur.

3. **Removed duplicate subscription check**: Deleted redundant duplicate
   subscription prevention in _requestSelected() since
   _subscribeCalendarEvents() already handles this at lines 221-227.

Note: The [nitpick] comment about loading states during await is a
performance enhancement suggestion, not a required fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Update src/panels/lovelace/cards/hui-calendar-card.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/panels/lovelace/cards/hui-calendar-card.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/panels/calendar/ha-panel-calendar.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor fetchCalendarEvents to use shared normalization utility

Eliminated code duplication by reusing normalizeSubscriptionEventData() in
fetchCalendarEvents(). After extracting date strings from the REST API
response format, we now convert to a subscription-like format and pass
it to the shared utility.

This ensures consistent event normalization across both REST API and
WebSocket subscription code paths, reducing maintenance burden and
potential for divergence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Move date normalization into normalizeSubscriptionEventData

The getCalendarDate helper is part of the normalization process and should be inside the normalization function. This makes normalizeSubscriptionEventData handle both REST API format (with dateTime/date objects) and subscription format (plain strings).

Changes:
- Moved getCalendarDate into normalizeSubscriptionEventData
- Updated CalendarEventSubscriptionData to accept string | any for start/end
- Made normalizeSubscriptionEventData return CalendarEvent | null for invalid dates
- Simplified fetchCalendarEvents to use the shared normalization
- Added null filtering in calendar card and panel event handlers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Replace any types with proper TypeScript types

Added proper types for calendar event data:
- CalendarDateValue: Union type for date values (string | {dateTime} | {date})
- CalendarEventRestData: Interface for REST API event responses
- Updated fetchCalendarEvents to use CalendarEventRestData[]
- Updated CalendarEventSubscriptionData to use CalendarDateValue
- Updated getCalendarDate to use proper type guards with 'in' operator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Unify CalendarEventRestData and CalendarEventSubscriptionData

Both interfaces had identical structures, so unified them into a single
CalendarEventSubscriptionData interface that is used for both REST API
responses and WebSocket subscription data.

Changes:
- Removed CalendarEventRestData interface
- Updated fetchCalendarEvents to use CalendarEventSubscriptionData
- Added documentation clarifying the interface is used for both APIs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* PR comments

* fix import

* fix import

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 13:18:53 +02:00
Aidan Timson
6278d9be2f Focus scrollable content on load for data tables (#51372)
* Focus scrollable content on load for data tables

* Improve typing

* Replace any types

* Refocus on heading click

* Remove

* Focus on checkbox enable/disable

* Cleanup

* Remove unneeded track autofocus

* Reduce guard need

* Remove

* Merge
2026-04-07 13:51:16 +03:00
Petar Petrov
ba2fef50d0 Preserve browser back/forward keyboard shortcuts in tab group (#51439) 2026-04-07 11:07:34 +01:00
ildar170975
a9774e74cf Map card editor: add UI for conditions (#30247)
* add conditions to MapCardConfig

* add conditions to Map card editor

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* Create ha-card-condition-numeric_state-no_entity.ts

* support no_entity state/numeric_state conditions

* support no_entity state/numeric_state conditions

* Create ha-card-condition-state-no_entity.ts

* support no_entity state/numeric_state conditions

* add UI for conditions

* remove comments

* linter

* resolving conflicts

* add no_entity property

* fix no_entity property

* Update ha-card-condition-or.ts

* Update ha-card-condition-state.ts

* Update ha-card-condition-and.ts

* Update ha-card-condition-not.ts

* Update ha-card-condition-editor.ts

* Update ha-card-conditions-editor.ts

* Update hui-map-card-editor.ts

* Update ha-selector.ts

* Update ha-selector-state.ts

* Create ha-selector-state-no_entity.ts

* linter

* Delete src/components/ha-selector/ha-selector-state-no_entity.ts

* revert

* Update ha-selector-state.ts

* Update ha-selector-state.ts

* Update selector.ts

* Update ha-card-condition-state.ts

* prettier

* fix addEntityToCondition

* Update src/translations/en.json

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

* pass no_entity to _schema

* pass no_entity to _schema

* forgot smth

* prettier

* prettier

* prettier

* prettier

* get translated states manually

* Update hui-map-card-editor.ts

* prettier

* add zones

* Update hui-map-card-editor.ts

* add getStatesDomain()

* use getStatesDomain()

* prettier

* prettier

* prettier

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* no_entity -> noEntity

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* add attribute "no_entity"

* no_entity -> noEntity

* no_entity -> noEntity

* add attribute "no_entity"

* prettier

* add "@state" to _presetStates

* Apply suggestions from code review

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

* format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-07 06:48:00 +00:00
Elias Axonov
ae3d6c77ca Fix iOS long-press context menu showing on images (#9549) (#51432) 2026-04-07 08:55:14 +03:00
renovate[bot]
4f3feced1b Update dependency @eslint/eslintrc to v3.3.5 (#51436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 08:53:44 +03:00
renovate[bot]
49dd217935 Update dependency eslint to v10.2.0 (#51437)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 08:52:56 +03:00
iluvdata
522cffffa8 fix flowType in showSubConfigFlowDialog in next_flow flows (#51389)
* fix flowType in config subentry flows/next_flow

* suggested changes
2026-04-07 08:34:34 +03:00
Petar Petrov
3124fbe08e Update eslint to v10.1.0 (#51352)
* Update eslint to v10.1.0

Migrate from eslint-plugin-import to eslint-plugin-import-x since the
original plugin uses removed ESLint 10 APIs (context.parserOptions,
context.parserPath). Airbnb-base non-import rules preserved via
FlatCompat with import rules stripped and replaced by import-x.

- eslint 9.39.4 → 10.1.0
- Add eslint-plugin-import-x 4.16.2
- Add @eslint/eslintrc and @eslint/js as explicit deps
- Remove --flag v10_config_lookup_from_file (now default)
- Replace /* eslint-env */ comment with config-based globals
- Update all import/ rule references to import-x/

* Remove unnecessary eslint-disable for generated gallery imports

The import-pages file is generated as .ts, so import-x/extensions
doesn't require an extension (ts: "never" in config).
2026-04-07 08:33:58 +03:00
renovate[bot]
c705d4e4a1 Update dependency @types/chromecast-caf-receiver to v6.0.26 (#51431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 20:13:59 +02:00
Alex Brown
446661915b Renamed manage lock added graceful degregation (#51293)
* Cleaned up experience when no pin support is possible to provide better messaging.

* Improvements to messaging depending on lock features

* Added documentation links

* Added prettier format
2026-04-06 16:46:00 +03:00
Brooke Hatton
6048356e01 Add Maintenance summary card and dashboard (#30372)
* Add a summary for battery status and a battery strategy

* some cleanup around location given its a sub view and naming

* Use the device name rather than entity name

* Refactor into a maintenance panel

* Adjust naming

* rename

* Add maintenance dashboard to built in dashboard list

* use grey for maintenance colour

* Fix typos in maintenance panel

- _searchParms -> _searchParams
- Add Event type to _back parameter
- Fix duplicate closing tag

* Move maintenance strategy to src/panels/maintenance/strategies/

- Move maintenance-view-strategy.ts to maintenance panel directory
- Update import paths in get-strategy.ts, home-summaries.ts, and hui-home-summary-card.ts
- Fix ha-floor-icon import path in maintenance-view-strategy.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-06 15:26:29 +03:00
Tom Carpenter
c44341331a Add Last 365 Days option to energy date selection (#51426)
* Add Last 365 Days option to energy date selection

There seem to be two use cases for the "Last 12 Months" option - those that expect it to produce whole months (original behaviour up to 2026.1, then again from 2026.4), and those that want an option for the last 365 days,

Given this descrepancy in expected behaviour, add an explicit "Last 365 days" option.

* Use subDays for last 365.
2026-04-06 13:44:51 +03:00
Aidan Timson
2d46304960 Update custom to community for user facing integrations, cards and badges (#51368)
* Update custom to community for user facing integrations, card, badges

* Rename as suggested
2026-04-06 11:57:01 +03:00
Wendelin
b5ff6a991d Migrate ha-textarea (#51377)
* Migrate ha-textarea

* Fix autogrow

* fix styles
2026-04-06 11:54:55 +03:00
Aidan Timson
28254ca0f2 Focus scrollable content on load for integrations (#51379) 2026-04-06 11:49:16 +03:00
Erwin Douna
8605c235ac Add loading state to energy dashboard (#51392)
* Add loading state to energy dashboard

* Add fade-in/-out

* Update src/panels/lovelace/components/hui-energy-period-selector.ts

Co-authored-by: Tom Carpenter <T.Carpenter@leeds.ac.uk>

* Feedback

* Feedback

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Tom Carpenter <T.Carpenter@leeds.ac.uk>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-06 06:36:59 +00:00
dependabot[bot]
8325161d39 Bump home-assistant/actions from d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d to 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 (#51430)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d to 5752577ea7cc5aefb064b0b21432f18fe4d6ba90.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](d56d093b9a...5752577ea7)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: 5752577ea7cc5aefb064b0b21432f18fe4d6ba90
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 09:32:30 +03:00
Niccolò Betto
90057854c8 Fix code input dialog undefined value concatenation (#51399) 2026-04-06 06:07:23 +00:00
Simon Lamon
04c8c82966 Increase gauge thickness for accessibility reasons (#51382)
Increase thickness for accessability reasons
2026-04-06 08:59:55 +03:00
renovate[bot]
16b3add987 Update dependency eslint-import-resolver-webpack to v0.13.11 (#51424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 08:48:42 +03:00
renovate[bot]
f0e171076e Update dependency @swc/helpers to v0.5.21 (#51429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 08:47:31 +03:00
renovate[bot]
48f0b78b95 Update dependency fuse.js to v7.2.0 (#51428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 08:47:09 +03:00
renovate[bot]
32728d91d7 Update dependency barcode-detector to v3.1.2 (#51417)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 20:59:00 +02:00
renovate[bot]
0915a3e29c Update dependency @codemirror/view to v6.41.0 (#51416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 20:26:38 +02:00
renovate[bot]
60c5888f6b Update dependency @rsdoctor/rspack-plugin to v1.5.7 (#51398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 18:07:11 +02:00
renovate[bot]
fb599b8b16 Update dependency @rspack/core to v1.7.11 (#51397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 13:29:21 +02:00
Bram Kragten
b1a390789d Always add options object to triggers and conditions (#51394) 2026-04-03 12:59:58 +02:00
Trevin Chow
76c0dd1f1f Guard against orphaned label references in device list (#51359) 2026-04-03 09:00:05 +00:00
Trevin Chow
96dacfdeca Fix TypeError in Voice Assistants expose page with manual entity filters (#51357) 2026-04-03 10:51:44 +02:00
Petar Petrov
5f28ed35d2 Only use inflight map for pending fragment translation loads (#51393) 2026-04-03 10:39:50 +02:00
Petar Petrov
edd7b4c3dc Fix fragment translation race condition returning stale localize (#51381) 2026-04-03 10:21:52 +02:00
Tim Ittermann
cbea8bbf44 fix: null value error on ha-form-integer (#51385)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-04-03 08:19:03 +00:00
Petar Petrov
23a41e4384 Fix next_flow dialog closing immediately after rendering (#51369) 2026-04-03 10:14:21 +02:00
renovate[bot]
f747580b43 Update dependency typescript-eslint to v8.58.0 (#51386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 06:56:48 +02:00
Brooke Hatton
98fc69674f Fix typos and syntax errors in ha-panel-climate.ts (#51388)
* Fix typos and syntax errors in ha-panel-climate.ts

- Rename _searchParms to _searchParams (typo fix)
- Fix duplicate closing hui-view-container tag
- Clean up formatting

* Fix duplicate hui-view-container closing tag in light and security panels
2026-04-03 06:56:29 +02:00
446 changed files with 20197 additions and 7819 deletions

View File

@@ -3,6 +3,9 @@ contact_links:
- name: Request a feature for the UI / Dashboards
url: https://github.com/orgs/home-assistant/discussions
about: Request a new feature for the Home Assistant frontend.
- name: Discuss UI or UX design
url: https://github.com/OpenHomeFoundation/ux-design/discussions
about: Share design feedback and discuss visual or UX changes with the design team.
- name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.

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

@@ -98,13 +98,13 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Upload frontend build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: hass_frontend/

View File

@@ -59,14 +59,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz

View File

@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -58,7 +58,7 @@ jobs:
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true

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
.gitignore vendored
View File

@@ -57,4 +57,4 @@ test/coverage/
# AI tooling
.claude
.cursor
.opencode

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

@@ -6,9 +6,9 @@ import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"import-x/no-extraneous-dependencies": "off",
"import-x/extensions": "off",
"import-x/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",

View File

@@ -6,7 +6,6 @@ import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
// eslint-disable-next-line import/no-relative-packages
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";

View File

@@ -11,9 +11,9 @@ export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./jimpower").then((mod) => mod.demoJimpower),
];
// eslint-disable-next-line import/no-mutable-exports
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfigIndex = 0;
// eslint-disable-next-line import/no-mutable-exports
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfig: Promise<DemoConfig> =
demoConfigs[selectedDemoConfigIndex]();

View File

@@ -1,6 +1,5 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import path from "node:path";
@@ -13,6 +12,7 @@ import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -22,8 +22,27 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
// Load airbnb-base via FlatCompat for non-import rules only.
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
return {
...rest,
plugins: Object.fromEntries(
Object.entries(plugins).filter(([key]) => key !== "import")
),
rules: Object.fromEntries(
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
),
settings: Object.fromEntries(
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
),
};
});
export default tseslint.config(
...compat.extends("airbnb-base"),
...airbnbConfigs,
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
@@ -31,6 +50,7 @@ export default tseslint.config(
tseslint.configs.stylistic,
wcConfigs["flat/recommended"],
a11yConfigs.recommended,
importX.flatConfigs.recommended,
{
plugins: {
"unused-imports": unusedImports,
@@ -58,7 +78,7 @@ export default tseslint.config(
},
settings: {
"import/resolver": {
"import-x/resolver": {
webpack: {
config: "./rspack.config.cjs",
},
@@ -87,12 +107,20 @@ export default tseslint.config(
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"import/prefer-default-export": "off",
"import/no-default-export": "off",
"import/no-unresolved": "off",
"import/no-cycle": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"no-use-before-define": "off",
"import/extensions": [
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
"import-x/named": "off",
"import-x/prefer-default-export": "off",
"import-x/no-default-export": "off",
"import-x/no-unresolved": "off",
"import-x/no-cycle": "off",
"import-x/extensions": [
"error",
"ignorePackages",
{
@@ -100,12 +128,24 @@ export default tseslint.config(
js: "never",
},
],
"import-x/no-mutable-exports": "error",
"import-x/no-amd": "error",
"import-x/first": "error",
"import-x/order": [
"error",
{ groups: [["builtin", "external", "internal"]] },
],
"import-x/newline-after-import": "error",
"import-x/no-absolute-path": "error",
"import-x/no-dynamic-require": "error",
"import-x/no-webpack-loader-syntax": "error",
"import-x/no-named-default": "error",
"import-x/no-self-import": "error",
"import-x/no-useless-path-segments": ["error", { commonjs: true }],
"import-x/no-import-module-exports": ["error", { exceptions: [] }],
"import-x/no-relative-packages": "error",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
// TypeScript rules
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
@@ -185,7 +225,6 @@ export default tseslint.config(
allowObjectTypes: "always",
},
],
"no-use-before-define": "off",
},
},
{
@@ -194,6 +233,12 @@ export default tseslint.config(
globals: globals.audioWorklet,
},
},
{
files: ["src/entrypoints/service-worker.ts"],
languageOptions: {
globals: globals.serviceworker,
},
},
{
plugins: {
html,

View File

@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "small"/"medium" | "medium" | Sets the button size. |
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
| disabled | Boolean | false | Disables the button and prevents user interaction. |

View File

@@ -10,7 +10,7 @@ import "../../../../src/components/input/ha-input";
import "../../../../src/components/input/ha-input-copy";
import "../../../../src/components/input/ha-input-multi";
import "../../../../src/components/input/ha-input-search";
import { localizeContext } from "../../../../src/data/context";
import { internationalizationContext } from "../../../../src/data/context";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copy": "Copy",
@@ -26,11 +26,19 @@ const LOCALIZE_KEYS: Record<string, string> = {
export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides localizeContext for ha-input-copy, ha-input-multi and ha-input-search
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: localizeContext,
initialValue: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
context: internationalizationContext,
initialValue: {
localize: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
},
});
}

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

@@ -0,0 +1,73 @@
---
title: Textarea
---
# Textarea `<ha-textarea>`
A multiline text input component supporting Home Assistant theming and validation, based on webawesome textarea.
Supports autogrow, hints, validation, and both material and outlined appearances.
## Implementation
### Example usage
```html
<ha-textarea label="Description" value="Hello world"></ha-textarea>
<ha-textarea
label="Notes"
placeholder="Type here..."
resize="auto"
></ha-textarea>
<ha-textarea label="Required field" required></ha-textarea>
<ha-textarea label="Disabled" disabled value="Can't edit this"></ha-textarea>
```
### API
This component is based on the webawesome textarea component.
**Slots**
- `label`: Custom label content. Overrides the `label` property.
- `hint`: Custom hint content. Overrides the `hint` property.
**Properties/Attributes**
| Name | Type | Default | Description |
| ------------------ | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
| value | String | - | The current value of the textarea. |
| label | String | "" | The textarea's label text. |
| hint | String | "" | The textarea's hint/helper text. |
| placeholder | String | "" | Placeholder text shown when the textarea is empty. |
| rows | Number | 4 | The number of visible text rows. |
| resize | "none"/"vertical"/"horizontal"/"both"/"auto" | "none" | Controls the textarea's resize behavior. |
| readonly | Boolean | false | Makes the textarea readonly. |
| disabled | Boolean | false | Disables the textarea and prevents user interaction. |
| required | Boolean | false | Makes the textarea a required field. |
| auto-validate | Boolean | false | Validates the textarea on blur instead of on form submit. |
| invalid | Boolean | false | Marks the textarea as invalid. |
| validation-message | String | "" | Custom validation message shown when the textarea is invalid. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
| name | String | - | The name of the textarea, submitted as a name/value pair with form data. |
| autocapitalize | "off"/"none"/"on"/"sentences"/"words"/"characters" | "" | Controls whether and how text input is automatically capitalized. |
| autocomplete | String | - | Indicates whether the browser's autocomplete feature should be used. |
| autofocus | Boolean | false | Automatically focuses the textarea when the page loads. |
| spellcheck | Boolean | true | Enables or disables the browser's spellcheck feature. |
| inputmode | "none"/"text"/"decimal"/"numeric"/"tel"/"search"/"email"/"url" | "" | Hints at the type of data for showing an appropriate virtual keyboard. |
| enterkeyhint | "enter"/"done"/"go"/"next"/"previous"/"search"/"send" | "" | Customizes the label or icon of the Enter key on virtual keyboards. |
#### CSS Parts
- `wa-base` - The underlying wa-textarea base wrapper.
- `wa-hint` - The underlying wa-textarea hint container.
- `wa-textarea` - The underlying wa-textarea textarea element.
**CSS Custom Properties**
- `--ha-textarea-padding-bottom` - Padding below the textarea host.
- `--ha-textarea-max-height` - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
- `--ha-textarea-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.

View File

@@ -0,0 +1,151 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-textarea";
@customElement("demo-components-ha-textarea")
export class DemoHaTextarea extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-textarea in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-textarea label="Default"></ha-textarea>
<ha-textarea
label="With value"
value="Hello world"
></ha-textarea>
<ha-textarea
label="With placeholder"
placeholder="Type here..."
></ha-textarea>
</div>
<h3>Autogrow</h3>
<div class="row">
<ha-textarea
label="Autogrow empty"
resize="auto"
></ha-textarea>
<ha-textarea
label="Autogrow with value"
resize="auto"
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
></ha-textarea>
</div>
<h3>States</h3>
<div class="row">
<ha-textarea
label="Disabled"
disabled
value="Disabled"
></ha-textarea>
<ha-textarea
label="Readonly"
readonly
value="Readonly"
></ha-textarea>
<ha-textarea label="Required" required></ha-textarea>
</div>
<div class="row">
<ha-textarea
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-textarea>
<ha-textarea
label="With hint"
hint="Supports Markdown"
></ha-textarea>
<ha-textarea
label="With rows"
.rows=${6}
placeholder="6 rows"
></ha-textarea>
</div>
<h3>No label</h3>
<div class="row">
<ha-textarea
placeholder="No label, just placeholder"
></ha-textarea>
<ha-textarea
resize="auto"
placeholder="No label, autogrow"
></ha-textarea>
</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;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
h3 {
margin: var(--ha-space-4) 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
h3:first-child {
margin-top: 0;
}
.row {
display: flex;
gap: var(--ha-space-4);
}
.row > * {
flex: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-textarea": DemoHaTextarea;
}
}

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: Box shadow
---

View File

@@ -0,0 +1,98 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
const SHADOWS = ["s", "m", "l"] as const;
@customElement("demo-misc-box-shadow")
export class DemoMiscBoxShadow extends LitElement {
protected render() {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<h2>${mode}</h2>
<div class="grid">
${SHADOWS.map(
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
>
${size}
</div>
`
)}
</div>
</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;
flex-direction: row;
gap: 48px;
padding: 48px;
}
.light,
.dark {
flex: 1;
background-color: var(--primary-background-color);
border-radius: 16px;
padding: 32px;
}
h2 {
margin: 0 0 24px;
font-size: 18px;
font-weight: 500;
color: var(--primary-text-color);
text-transform: capitalize;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
}
.box {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
border-radius: 12px;
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-size: 16px;
font-weight: 500;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-box-shadow": DemoMiscBoxShadow;
}
}

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

@@ -1,7 +1,5 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";

View File

@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress";
import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -8,6 +7,7 @@ import "../../src/components/ha-button";
import "../../src/components/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
@@ -60,7 +60,7 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<mwc-linear-progress indeterminate></mwc-linear-progress>
<ha-progress-bar indeterminate></ha-progress-bar>
`
: nothing}
${networkIssue || this._networkInfoError

View File

@@ -1,6 +1,6 @@
export default {
"*.?(c|m){js,ts}": [
"eslint --flag v10_config_lookup_from_file --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"eslint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"prettier --cache --write",
"lit-analyzer --quiet",
],

View File

@@ -8,8 +8,8 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
@@ -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.40.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",
@@ -59,22 +60,12 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-checkbox": "0.27.0",
"@material/mwc-dialog": "0.27.0",
"@material/mwc-drawer": "0.27.0",
"@material/mwc-fab": "0.27.0",
"@material/mwc-floating-label": "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-linear-progress": "0.27.0",
"@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-select": "0.27.0",
"@material/mwc-switch": "0.27.0",
"@material/mwc-textarea": "0.27.0",
"@material/mwc-textfield": "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",
@@ -82,14 +73,14 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.20",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.1",
"barcode-detector": "3.1.2",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -102,13 +93,13 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.1.0",
"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",
@@ -116,7 +107,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.5",
"marked": "18.0.1",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -143,17 +134,19 @@
"@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",
"@html-eslint/eslint-plugin": "0.58.1",
"@lokalise/node-api": "15.6.1",
"@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",
"@lokalise/node-api": "15.7.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.6",
"@rspack/core": "1.7.10",
"@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",
"@types/chromecast-caf-receiver": "6.0.25",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -169,16 +162,17 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.2",
"@vitest/coverage-v8": "4.1.4",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "9.39.4",
"eslint": "10.2.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -186,13 +180,14 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.5.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.1",
"jsdom": "29.0.2",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
@@ -200,17 +195,17 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.1",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.3",
"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.57.2",
"typescript": "6.0.3",
"typescript-eslint": "8.58.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.2",
"vitest": "4.1.4",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
@@ -221,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

@@ -21,6 +21,9 @@ export const filterNavigationPages = (
if (page.path === "#external-app-configuration") {
return hass.auth.external?.config.hasSettingsScreen;
}
if (page.adminOnly && !hass.user?.is_admin) {
return false;
}
// Only show Bluetooth page if there are Bluetooth config entries
if (page.component === "bluetooth") {
return options.hasBluetoothConfigEntries ?? false;

View File

@@ -27,6 +27,7 @@ export type DateRange =
| "this_year"
| "now-7d"
| "now-30d"
| "now-365d"
| "now-12m"
| "now-1h"
| "now-12h"
@@ -102,6 +103,11 @@ export const calcDateRange = (
),
calcDate(today, endOfMonth, locale, hassConfig),
];
case "now-365d":
return [
calcDate(today, subDays, locale, hassConfig, 365),
calcDate(today, subDays, locale, hassConfig, 0),
];
case "now-1h":
return [
calcDate(today, subHours, locale, hassConfig, 1),

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

@@ -38,6 +38,14 @@ export interface HASSDomEvent<T> extends Event {
detail: T;
}
export type HASSDomTargetEvent<T extends EventTarget> = Event & {
target: T;
};
export type HASSDomCurrentTargetEvent<T extends EventTarget> = Event & {
currentTarget: T;
};
/**
* Dispatches a custom event with an optional detail value.
*

View File

@@ -7,7 +7,8 @@ export type LeafletModuleType = typeof import("leaflet");
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
@@ -32,7 +33,12 @@ export const setupLeafletMap = async (
markerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(markerClusterStyle);
map.setView([52.3731339, 4.8903147], 13);
if (initialView) {
map.setView(
[initialView.latitude, initialView.longitude],
initialView.zoom ?? 13
);
}
const tileLayer = createTileLayer(Leaflet).addTo(map);

View File

@@ -242,14 +242,18 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
},
};
export const getStates = (
export const getStatesDomain = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
domain: string,
attribute?: string | undefined
): string[] => {
const domain = computeStateDomain(state);
const result: string[] = [];
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
result.push(...FIXED_DOMAIN_STATES[domain]);
} else if (
@@ -260,21 +264,7 @@ export const getStates = (
result.push(...FIXED_DOMAIN_ATTRIBUTE_STATES[domain][attribute]);
}
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "device_tracker":
case "person":
if (!attribute) {
@@ -293,6 +283,37 @@ export const getStates = (
);
}
break;
}
return result;
};
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const result: string[] = [];
// Fixed values based on a domain
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
@@ -353,9 +374,5 @@ export const getStates = (
break;
}
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
}
return [...new Set(result)];
};

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

@@ -10,13 +10,10 @@
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {

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

@@ -18,15 +18,16 @@ import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { transform } from "../../common/decorators/transform";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { themesContext } from "../../data/context";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
@@ -74,8 +75,11 @@ export class HaChartBase extends LitElement {
public extraComponents?: any[];
@state()
@consume({ context: themesContext, subscribe: true })
_themes!: Themes;
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({
transformer: ({ themes }) => themes,
})
private _themes!: Themes;
@state() private _isZoomed = false;
@@ -174,6 +178,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
@@ -192,6 +197,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
@@ -267,6 +273,9 @@ export class HaChartBase extends LitElement {
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series) {
this._updateSankeyRoam();
}
}
}
@@ -451,6 +460,22 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = sankeySeries.zoom !== 1;
this._isZoomed = zoomed;
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
@@ -549,6 +574,7 @@ export class HaChartBase extends LitElement {
...this._createOptions(),
series: this._getSeries(),
});
this._updateSankeyRoam();
} finally {
this._loading = false;
}
@@ -988,6 +1014,26 @@ export class HaChartBase extends LitElement {
if (!this.chart) {
return;
}
// Handle sankey chart double-click zoom
const option = this.chart.getOption();
const allSeries = option.series as any[];
const sankeySeries = allSeries?.filter((s: any) => s.type === "sankey");
if (sankeySeries?.length) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 2,
})),
});
this._isZoomed = true;
}
if (sankeySeries.length === allSeries?.length) {
return;
}
}
const range = this._isZoomed
? [0, 100]
: [
@@ -1012,6 +1058,37 @@ export class HaChartBase extends LitElement {
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 1,
center: null,
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
}
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
})),
});
}
}
private _handleDataZoomEvent(e: any) {
@@ -1382,5 +1459,6 @@ declare global {
start: number;
end: number;
};
"chart-sankeyroam": { zoom: number };
}
}

View File

@@ -64,6 +64,8 @@ export class HaSankeyChart extends LitElement {
public chart?: EChartsType;
private _currentZoom = 1;
@state() private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
@@ -84,11 +86,13 @@ export class HaSankeyChart extends LitElement {
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
@chart-sankeyroam=${this._handleChartSankeyRoam}
></ha-chart-base>`;
}
@@ -109,6 +113,10 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartSankeyRoam = (ev: CustomEvent) => {
this._currentZoom = ev.detail.zoom;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
@@ -180,6 +188,7 @@ export class HaSankeyChart extends LitElement {
})),
links,
draggable: false,
scaleLimit: { min: 1, max: 4 },
orient: this.vertical ? "vertical" : "horizontal",
nodeWidth: 15,
nodeGap: NODE_GAP,
@@ -210,7 +219,7 @@ export class HaSankeyChart extends LitElement {
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const availableWidth = params.rect.width + 6;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
const fontSize = Math.min(
FONT_SIZE,
(availableWidth / wordWidth) * FONT_SIZE
@@ -223,7 +232,7 @@ export class HaSankeyChart extends LitElement {
};
}
const availableHeight = params.rect.height + 8; // account for the margin
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE

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

@@ -1,8 +1,8 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -12,12 +12,12 @@ import type {
} from "../../data/history";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
import "../ha-fab";
import "../ha-button";
import "../ha-svg-icon";
import "./state-history-chart-line";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import "./state-history-chart-timeline";
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
const CANVAS_TIMELINE_ROWS_CHUNK = 10; // Split up the canvases to avoid hitting the render limit
@@ -150,16 +150,14 @@ export class StateHistoryCharts extends LitElement {
this._renderHistoryItem(item, index)
)}`}
${this.syncCharts && this._hasZoomedCharts
? html`<ha-fab
slot="fab"
? html`<ha-button
size="large"
class="reset-button"
.label=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
@click=${this._handleGlobalZoomReset}
>
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
</ha-fab>`
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.components.history_charts.zoom_reset")}
</ha-button>`
: nothing}
`;
}
@@ -448,6 +446,7 @@ export class StateHistoryCharts extends LitElement {
bottom: calc(24px + var(--safe-area-inset-bottom));
right: calc(24px + var(--safe-area-inset-bottom));
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`;
}

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

@@ -16,13 +16,18 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { localeContext, localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
@@ -103,16 +108,13 @@ export interface DataTableRowData {
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@state()
@consume({ context: localizeContext, subscribe: true })
private _localize?: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private _locale?: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean }) public narrow = false;
@@ -166,6 +168,10 @@ export class HaDataTable extends LitElement {
@query("slot[name='header']") private _header!: HTMLSlotElement;
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
@query("lit-virtualizer") private _scroller?: HTMLElement;
@state() private _collapsedGroups: string[] = [];
@state() private _lastSelectedRowId: string | null = null;
@@ -242,16 +248,30 @@ export class HaDataTable extends LitElement {
this.updateComplete.then(() => this._calcTableHeight());
}
protected updated() {
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
if (!header) {
protected updated(changedProps: PropertyValues) {
if (!this._headerRow) {
return;
}
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
if (this._headerRow.scrollWidth > this._headerRow.clientWidth) {
this.style.setProperty(
"--table-row-width",
`${this._headerRow.scrollWidth}px`
);
} else {
this.style.removeProperty("--table-row-width");
}
const activeElement = deepActiveElement();
if (
changedProps.has("selectable") ||
(!this.autoHeight &&
activeElement &&
AUTO_FOCUS_ALLOWED_ACTIVE_TAGS.includes(activeElement.tagName))
) {
this._focusScroller();
}
}
public willUpdate(properties: PropertyValues) {
@@ -507,7 +527,9 @@ export class HaDataTable extends LitElement {
<div class="mdc-data-table__row" role="row">
<div class="mdc-data-table__cell grows center" role="cell">
${this.noDataText ||
this._localize?.("ui.components.data-table.no-data") ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"}
</div>
</div>
@@ -517,11 +539,12 @@ export class HaDataTable extends LitElement {
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
this._localize,
this._locale,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -691,7 +714,7 @@ export class HaDataTable extends LitElement {
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this._locale?.language
this._i18n?.locale?.language
)
: filteredData;
@@ -829,8 +852,10 @@ export class HaDataTable extends LitElement {
): Promise<DataTableRowData[]> => filterData(data, columns, filter)
);
private _handleHeaderClick(ev: Event) {
const columnId = (ev.currentTarget as any).columnId;
private _handleHeaderClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { columnId: string }>
) {
const columnId = ev.currentTarget.columnId;
if (!this.columns[columnId].sortable) {
return;
}
@@ -848,11 +873,12 @@ export class HaDataTable extends LitElement {
column: columnId,
direction: this.sortDirection,
});
this._focusScroller();
}
private _handleHeaderRowCheckboxClick(ev: Event) {
const checkbox = ev.target as HaCheckbox;
if (checkbox.checked) {
private _handleHeaderRowCheckboxClick(ev: HASSDomTargetEvent<HaCheckbox>) {
if (ev.target.checked) {
this.selectAll();
} else {
this._checkedRows = [];
@@ -861,14 +887,25 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
private _handleRowCheckboxClicked = (ev: Event) => {
const checkbox = ev.currentTarget as HaCheckbox;
const rowId = (checkbox as any).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,
this._localize,
this._locale,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -900,7 +937,7 @@ export class HaDataTable extends LitElement {
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!checkbox.checked) {
} else if (checkboxElement.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
}
@@ -938,7 +975,9 @@ export class HaDataTable extends LitElement {
return checkedRows;
}
private _handleRowClick = (ev: Event) => {
private _handleRowClick = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { rowId: string }>
) => {
if (
ev
.composedPath()
@@ -954,14 +993,13 @@ export class HaDataTable extends LitElement {
) {
return;
}
const rowId = (ev.currentTarget as any).rowId;
const rowId = ev.currentTarget.rowId;
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
};
private _setTitle(ev: Event) {
const target = ev.currentTarget as HTMLElement;
if (target.scrollWidth > target.offsetWidth) {
target.setAttribute("title", target.innerText);
private _setTitle(ev: HASSDomCurrentTargetEvent<HTMLElement>) {
if (ev.currentTarget.scrollWidth > ev.currentTarget.offsetWidth) {
ev.currentTarget.setAttribute("title", ev.currentTarget.innerText);
}
}
@@ -983,6 +1021,12 @@ export class HaDataTable extends LitElement {
this._debounceSearch((ev.target as HTMLInputElement).value);
}
private _focusScroller(): void {
this._scroller?.focus({
preventScroll: true,
});
}
private async _calcTableHeight() {
if (this.autoHeight) {
return;
@@ -992,23 +1036,27 @@ export class HaDataTable extends LitElement {
}
@eventOptions({ passive: true })
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
this._savedScrollPos = e.target.scrollTop;
this.renderRoot.querySelector(".mdc-data-table__header-row")!.scrollLeft = (
e.target as HTMLDivElement
).scrollLeft;
if (this._headerRow) {
this._headerRow.scrollLeft = e.target.scrollLeft;
}
}
@eventOptions({ passive: true })
private _scrollContent(e: Event) {
this.renderRoot.querySelector("lit-virtualizer")!.scrollLeft = (
e.target as HTMLDivElement
).scrollLeft;
private _scrollContent(e: HASSDomTargetEvent<HTMLDivElement>) {
if (!this._scroller) {
return;
}
this._scroller.scrollLeft = e.target.scrollLeft;
}
private _collapseGroup = (ev: Event) => {
const groupName = (ev.currentTarget as any).group;
private _collapseGroup = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
const groupName = ev.currentTarget.group;
if (this._collapsedGroups.includes(groupName)) {
this._collapsedGroups = this._collapsedGroups.filter(
(grp) => grp !== groupName
@@ -1431,6 +1479,15 @@ export class HaDataTable extends LitElement {
contain: size layout !important;
overscroll-behavior: contain;
}
lit-virtualizer:focus,
lit-virtualizer:focus-visible {
outline: none;
}
ha-checkbox {
padding: var(--ha-space-1);
}
`,
];
}

View File

@@ -3,6 +3,7 @@ import { consume, type ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
@@ -12,16 +13,13 @@ import {
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { configContext, internationalizationContext } from "../../data/context";
import { TimeZone } from "../../data/translation";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { haStyleScrollbar } from "../../resources/styles";
import type { ValueChangedEvent } from "../../types";
import type { HomeAssistantConfig, ValueChangedEvent } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import type { HaFilterChip } from "../chips/ha-filter-chip";
@@ -48,16 +46,15 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
public timePicker = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
@@ -87,12 +84,20 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
? formatCallyDateRange(
this.startDate,
this.endDate,
this.locale,
this.hassConfig
this._i18n?.locale,
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
if (this.timePicker && this.startDate && this.endDate) {
this._timeValue = {
@@ -144,12 +149,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<div class="range">
<calendar-range
.value=${this._dateValue}
.locale=${this.locale.language}
.locale=${this._i18n.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this.locale)}
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
>
<ha-icon-button-prev
tabindex="-1"
@@ -162,7 +167,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next
@@ -176,9 +181,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this.locale}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
@@ -187,9 +192,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this.locale}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
@@ -203,19 +208,33 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
</div>
<div class="footer">
<ha-button appearance="plain" @click=${this._cancel}
>${this.localize("ui.common.cancel")}</ha-button
>${this._i18n.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
>${this.localize("ui.components.date-range-picker.select")}</ha-button
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
>
</div>`;
}
private _focusToday() {
const date = new Date();
this._focusDate = formatISODateOnly(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = formatISODateOnly(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
}
private _cancel() {
@@ -255,12 +274,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
}
if (this.locale.time_zone === TimeZone.server) {
if (this._i18n.locale.time_zone === TimeZone.server) {
startDate = new Date(
new TZDate(startDate, this.hassConfig.time_zone).getTime()
new TZDate(startDate, this._hassConfig.time_zone).getTime()
);
endDate = new Date(
new TZDate(endDate, this.hassConfig.time_zone).getTime()
new TZDate(endDate, this._hassConfig.time_zone).getTime()
);
}
@@ -286,8 +305,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
}

View File

@@ -3,6 +3,7 @@ import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -14,12 +15,10 @@ import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { configContext, internationalizationContext } from "../../data/context";
import type { HomeAssistantConfig } from "../../types";
import "../ha-bottom-sheet";
import "../ha-icon-button";
import "../ha-icon-button-next";
@@ -43,16 +42,15 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -117,8 +115,8 @@ export class HaDateRangePicker extends LitElement {
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.locale, this.hassConfig, key);
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
});
}
@@ -133,47 +131,50 @@ export class HaDateRangePicker extends LitElement {
${!this.minimal
? html`<ha-textarea
id="field"
mobile-multiline
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
))}
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this.localize("ui.components.date-range-picker.end_date")}
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this.localize("ui.common.previous")}
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.localize("ui.common.next")}
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
@@ -181,7 +182,7 @@ export class HaDateRangePicker extends LitElement {
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this.localize(
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
@@ -289,8 +290,8 @@ export class HaDateRangePicker extends LitElement {
this.startDate,
this.endDate,
forward,
this.locale,
this.hassConfig
this._i18n.locale,
this._hassConfig
);
this.startDate = start;
this.endDate = end;
@@ -336,14 +337,7 @@ export class HaDateRangePicker extends LitElement {
private _setTextareaFocusStyle(focused: boolean) {
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
const foundation = (textarea as any).mdcFoundation;
if (foundation) {
if (focused) {
foundation.activateFocus();
} else {
foundation.deactivateFocus();
}
}
textarea.setFocused(focused);
}
}

View File

@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import { mdiBackspace, mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
@@ -10,12 +11,10 @@ import {
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { transform } from "../../common/decorators/transform";
import { configContext, internationalizationContext } from "../../data/context";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { HomeAssistantConfig } from "../../types";
import "../ha-button";
import type { DatePickerDialogParams } from "../ha-date-input";
import "../ha-dialog";
@@ -40,16 +39,15 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
LitElement
) {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private hassConfig!: ContextType<typeof configContext>;
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@state() private _value?: {
year: string;
@@ -74,14 +72,26 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? new Date(`${this.params.value.split("T")[0]}T00:00:00`)
: new Date();
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._value = this.params.value
? {
year: this._pickerYear,
title: formatDateShort(date, this.locale, this.hassConfig),
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
this._i18n.locale,
this._hassConfig
),
}
: undefined;
}
@@ -95,7 +105,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
open
width="small"
.headerTitle=${this._value?.title ||
this.localize("ui.dialogs.date-picker.title")}
this._i18n.localize("ui.dialogs.date-picker.title")}
.headerSubtitle=${this._value?.year}
header-subtitle-position="above"
>
@@ -103,7 +113,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? html`
<ha-icon-button
.path=${mdiBackspace}
.label=${this.localize("ui.dialogs.date-picker.clear")}
.label=${this._i18n.localize("ui.dialogs.date-picker.clear")}
slot="headerActionItems"
@click=${this._clear}
></ha-icon-button>
@@ -131,7 +141,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next tabindex="-1" slot="next"></ha-icon-button-next>
@@ -143,10 +153,10 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.localize("ui.common.cancel")}
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this.localize("ui.common.ok")}
${this._i18n.localize("ui.common.ok")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`;
@@ -164,23 +174,39 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? new Date(`${value.split("T")[0]}T00:00:00`)
: new Date();
this._value = {
year: formatDateYear(date, this.locale, this.hassConfig),
title: formatDateShort(date, this.locale, this.hassConfig),
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString:
value || formatISODateOnly(date, this.locale, this.hassConfig),
value || formatISODateOnly(date, this._i18n.locale, this._hassConfig),
};
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
}
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
}

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

@@ -38,6 +38,8 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
@@ -124,7 +126,8 @@ export class HaEntityStatePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled || !this.entityId}
.disabled=${this.disabled ||
(!this.entityId && this.noEntity === false)}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??

View File

@@ -27,7 +27,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
* @cssprop --ha-button-height - The height of the button.
* @cssprop --ha-button-border-radius - The border radius of the button. defaults to `var(--ha-border-radius-pill)`.
*
* @attr {("small"|"medium")} size - Sets the button size.
* @attr {("small"|"medium"|"large")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
@@ -62,6 +62,7 @@ export class HaButton extends Button {
transition: background-color var(--ha-animation-duration-fast)
ease-out;
text-wrap: wrap;
box-shadow: var(--ha-button-box-shadow);
}
:host([size="small"]) .button {
@@ -73,6 +74,14 @@ export class HaButton extends Button {
--wa-form-control-padding-inline: var(--ha-space-3);
}
:host([size="large"]) .button {
--wa-form-control-height: var(
--ha-button-height,
var(--button-height, 48px)
);
font-size: var(--ha-font-size-l);
}
:host([variant="brand"]) {
--button-color-fill-normal-active: var(
--ha-color-fill-primary-normal-active

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

@@ -7,7 +7,7 @@ import memoizeOne from "memoize-one";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import { localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import type { UiColorExtraOption } from "../data/selector";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
@@ -55,8 +55,8 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
render() {
const effectiveValue = this.value ?? this.defaultColor ?? "";
@@ -73,7 +73,7 @@ export class HaColorPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.notFoundLabel=${this.localize?.(
.notFoundLabel=${this._i18n?.localize?.(
"ui.components.color-picker.no_colors_found"
)}
.getAdditionalItems=${this._getAdditionalItems}
@@ -103,7 +103,7 @@ export class HaColorPicker extends LitElement {
{
id: searchString,
primary:
this.localize?.("ui.components.color-picker.custom_color") ||
this._i18n?.localize?.("ui.components.color-picker.custom_color") ||
"Custom color",
secondary: searchString,
},
@@ -130,14 +130,15 @@ export class HaColorPicker extends LitElement {
const items: PickerComboBoxItem[] = [];
const defaultSuffix =
this.localize?.("ui.components.color-picker.default") || "Default";
this._i18n?.localize?.("ui.components.color-picker.default") ||
"Default";
const addDefaultSuffix = (label: string, isDefault: boolean) =>
isDefault && defaultSuffix ? `${label} (${defaultSuffix})` : label;
if (includeNone) {
const noneLabel =
this.localize?.("ui.components.color-picker.none") || "None";
this._i18n?.localize?.("ui.components.color-picker.none") || "None";
items.push({
id: "none",
primary: addDefaultSuffix(noneLabel, defaultColor === "none"),
@@ -147,7 +148,7 @@ export class HaColorPicker extends LitElement {
if (includeState) {
const stateLabel =
this.localize?.("ui.components.color-picker.state") || "State";
this._i18n?.localize?.("ui.components.color-picker.state") || "State";
items.push({
id: "state",
primary: addDefaultSuffix(stateLabel, defaultColor === "state"),
@@ -170,7 +171,7 @@ export class HaColorPicker extends LitElement {
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.localize?.(
this._i18n?.localize?.(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color;
items.push({
@@ -227,7 +228,7 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiInvertColorsOff}></ha-svg-icon>
<span slot="headline">
${this.localize?.("ui.components.color-picker.none") || "None"}
${this._i18n?.localize?.("ui.components.color-picker.none") || "None"}
</span>
`;
}
@@ -235,7 +236,8 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>
<span slot="headline">
${this.localize?.("ui.components.color-picker.state") || "State"}
${this._i18n?.localize?.("ui.components.color-picker.state") ||
"State"}
</span>
`;
}
@@ -243,7 +245,7 @@ export class HaColorPicker extends LitElement {
const extraOption = this.extraOptions?.find((o) => o.value === value);
const label =
extraOption?.label ||
this.localize?.(
this._i18n?.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) ||
value;

View File

@@ -14,7 +14,7 @@ import { ifDefined } from "lit/directives/if-defined";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { withViewTransition } from "../common/util/view-transition";
import { localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-dialog-header";
@@ -123,13 +123,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
// @consume({ context: configContext, subscribe: true })
// private _hassConfig?: ContextType<typeof configContext>;
@state()
private _bodyScrolled = false;
@@ -184,7 +184,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.localize?.("ui.common.close") ?? "Close"}
.label=${this._i18n?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
@@ -222,13 +222,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this.auth?.external && isIosApp(this.auth.external)) {
// if (this._hassConfig?.auth.external && isIosApp(this._hassConfig.auth.external)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-dialog-autofocus";
// }
// this.auth.external.fireMessage({
// this._hassConfig.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,

View File

@@ -2,12 +2,7 @@ import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import {
authContext,
configContext,
connectionContext,
themesContext,
} from "../data/context";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
domainIcon,
@@ -38,12 +33,8 @@ export class HaDomainIcon extends LitElement {
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: themesContext, subscribe: true })
private _themes?: ContextType<typeof themesContext>;
@state()
@consume({ context: authContext, subscribe: true })
private _auth?: ContextType<typeof authContext>;
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
protected render() {
if (this.icon) {
@@ -59,8 +50,8 @@ export class HaDomainIcon extends LitElement {
}
const icon = domainIcon(
this._connection,
this._hassConfig,
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
@@ -86,9 +77,9 @@ export class HaDomainIcon extends LitElement {
{
domain: this.domain!,
type: "icon",
darkOptimized: this._themes?.darkMode,
darkOptimized: this._hassUi?.themes.darkMode,
},
this._auth?.data.hassUrl
this._hassConfig?.auth.data.hassUrl
);
return html`
<img

View File

@@ -1,60 +0,0 @@
import { FabBase } from "@material/mwc-fab/mwc-fab-base";
import { styles } from "@material/mwc-fab/mwc-fab.css";
import { customElement } from "lit/decorators";
import { css } from "lit";
import { mainWindow } from "../common/dom/get_main_window";
@customElement("ha-fab")
export class HaFab extends FabBase {
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)");
}
static override styles = [
styles,
css`
:host {
--mdc-typography-button-text-transform: none;
--mdc-typography-button-font-size: var(--ha-font-size-l);
--mdc-typography-button-font-family: var(--ha-font-family-body);
--mdc-typography-button-font-weight: var(--ha-font-weight-medium);
}
:host .mdc-fab--extended {
border-radius: var(
--ha-button-border-radius,
var(--ha-border-radius-pill)
);
}
:host .mdc-fab.mdc-fab--extended .ripple {
border-radius: var(
--ha-button-border-radius,
var(--ha-border-radius-pill)
);
}
:host .mdc-fab--extended .mdc-fab__icon {
margin-inline-start: -8px;
margin-inline-end: 12px;
direction: var(--direction);
}
:disabled {
--mdc-theme-secondary: var(--disabled-text-color);
cursor: not-allowed !important;
}
`,
// safari workaround - must be explicit
mainWindow.document.dir === "rtl"
? css`
:host .mdc-fab--extended .mdc-fab__icon {
direction: rtl;
}
`
: css``,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-fab": HaFab;
}
}

View File

@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiDelete, mdiFileUpload } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -12,6 +11,7 @@ import type { HomeAssistant } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
import "./progress/ha-progress-bar";
declare global {
interface HASSDomEvents {
@@ -100,10 +100,11 @@ export class HaFileUpload extends LitElement {
</div>`
: nothing}
</div>
<mwc-linear-progress
<ha-progress-bar
.indeterminate=${!this.progress}
.progress=${this.progress ? this.progress / 100 : undefined}
></mwc-linear-progress>
.value=${this.progress}
loading
></ha-progress-bar>
</div>`
: html`<label
for=${this.value ? "" : "input"}
@@ -319,7 +320,7 @@ export class HaFileUpload extends LitElement {
--mdc-button-outline-color: var(--primary-color);
--ha-icon-button-size: 24px;
}
mwc-linear-progress {
ha-progress-bar {
width: 100%;
padding: 8px 32px;
box-sizing: border-box;

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

@@ -100,9 +100,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
inputMode="numeric"
.label=${this.label}
.hint=${this.helper}
.value=${this.data !== undefined && this.data !== null
? this.data.toString()
: ""}
.value=${this.data?.toString() ?? ""}
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
@@ -201,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 -36,-2 L -44,-1 A 1,1,0,0,0,-44,1 L -36,2 A 2,2,0,0,0,-36,-2 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;
}
}
}
@@ -243,19 +254,19 @@ export class HaGauge extends LitElement {
.levels-base {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 6;
stroke-width: 12;
stroke-linecap: butt;
}
.level {
fill: none;
stroke-width: 6;
stroke-width: 12;
stroke-linecap: butt;
}
.value {
fill: none;
stroke-width: 6;
stroke-width: 12;
stroke: var(--gauge-color);
stroke-linecap: butt;
transition: stroke-dashoffset 1s ease 0s;

View File

@@ -440,10 +440,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
width: max(
var(--body-width),
var(--ha-generic-picker-min-width, 250px)
);
max-width: var(
--ha-generic-picker-max-width,
max(var(--body-width), 250px)
max(var(--body-width), var(--ha-generic-picker-min-width, 250px))
);
max-height: 500px;
height: 70vh;

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,7 +1,6 @@
import type { PropertyValues } from "lit";
import { ReactiveElement, render, html } from "lit";
import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event";

View File

@@ -1,14 +1,19 @@
import Fuse from "fuse.js";
import { mdiDevices, mdiPlus, 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 } 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,24 +275,9 @@ export class HaNavigationPicker extends LitElement {
addGroup("related", related);
addGroup("dashboards", dashboards);
addGroup("views", views);
addGroup("apps", apps);
addGroup("other_routes", otherRoutes);
if (
searchString &&
!this._navigationItems.some((navItem) => navItem.id === searchString)
) {
items.push({
id: searchString,
primary: this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
),
secondary: `"${searchString}"`,
icon_path: mdiPlus,
sorting_label: searchString,
group: "other_routes",
});
}
return items;
};
@@ -287,26 +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",
};
@@ -322,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,
};
@@ -349,6 +417,7 @@ export class HaNavigationPicker extends LitElement {
...related,
...dashboards,
...views,
...apps,
...otherRoutes,
];
@@ -371,6 +440,7 @@ export class HaNavigationPicker extends LitElement {
...relatedItems,
...this._navigationGroups.dashboards,
...this._navigationGroups.views,
...this._navigationGroups.apps,
...this._navigationGroups.other_routes,
];
};
@@ -414,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",
@@ -446,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",
@@ -471,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

@@ -15,7 +15,7 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { localeContext, localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
multiTermSortedSearch,
@@ -162,12 +162,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private i18n!: ContextType<typeof internationalizationContext>;
@state() private _items: PickerComboBoxItem[] = [];
@@ -222,9 +218,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
const searchLabel =
this.label ??
(this.allowCustomValue
? (this.localize?.("ui.components.combo-box.search_or_custom") ??
? (this.i18n.localize?.("ui.components.combo-box.search_or_custom") ??
"Search | Add custom value")
: (this.localize?.("ui.common.search") ?? "Search"));
: (this.i18n.localize?.("ui.common.search") ?? "Search"));
return html`<ha-input-search
appearance="outlined"
@@ -351,7 +347,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return caseInsensitiveStringCompare(
sortLabelA,
sortLabelB,
this.locale?.language ?? navigator.language
this.i18n.locale?.language ?? navigator.language
);
});
}
@@ -368,7 +364,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: this._search,
primary:
this.customValueLabel ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
this.i18n.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${this._search}"`,
icon_path: mdiPlus,
@@ -402,10 +398,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.localize?.("ui.components.combo-box.no_match") ||
this.i18n.localize?.("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.localize?.("ui.components.combo-box.no_items") ||
this.i18n.localize?.("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
@@ -497,7 +493,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: searchString,
primary:
this.customValueLabel ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
this.i18n.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,

View File

@@ -1,4 +1,4 @@
import { consume } from "@lit/context";
import { consume, type ContextType } from "@lit/context";
import { mdiClose, mdiMenuDown } from "@mdi/js";
import {
css,
@@ -11,9 +11,8 @@ import {
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { localizeContext } from "../data/context";
import { internationalizationContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon";
@@ -34,8 +33,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
public async focus() {
await this.updateComplete;
@@ -89,7 +88,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
this.localize("ui.components.combo-box.unknown_item")}
this._i18n?.localize("ui.components.combo-box.unknown_item")}
</div>`
: nothing}
${showClearIcon

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

@@ -98,6 +98,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.noEntity=${this.selector.state?.no_entity ?? false}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-value

View File

@@ -65,21 +65,20 @@ export class HaTextSelector extends LitElement {
.label=${this.label}
.placeholder=${this.placeholder}
.value=${this.value || ""}
.helper=${this.helper}
helperPersistent
.hint=${this.helper}
.disabled=${this.disabled}
@input=${this._handleChange}
autocapitalize="none"
.autocomplete=${this.selector.text?.autocomplete}
spellcheck="false"
.required=${this.required}
autogrow
resize="auto"
></ha-textarea>`;
}
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

@@ -23,7 +23,10 @@ export class HaSlider extends Slider {
--marker-height: calc(var(--ha-slider-track-size, 4px) / 2);
--marker-width: calc(var(--ha-slider-track-size, 4px) / 2);
--wa-color-surface-default: var(--card-background-color);
--wa-color-neutral-fill-normal: var(--disabled-color);
--wa-color-neutral-fill-normal: var(
--ha-slider-track-color,
var(--disabled-color)
);
--wa-tooltip-background-color: var(
--ha-tooltip-background-color,
var(--secondary-background-color)
@@ -53,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

@@ -13,6 +13,24 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
}
private _handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
};
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;

View File

@@ -1,12 +1,10 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -200,7 +198,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
return html`
<div class="mdc-chip-set items">
<div class="items">
${floorIds.length
? floorIds.map(
(floor_id) => html`
@@ -1233,34 +1231,30 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this.hass?.locale.language ?? navigator.language
);
static get styles(): CSSResultGroup {
return css`
.add-target-wrapper {
display: flex;
justify-content: flex-start;
margin-top: var(--ha-space-3);
}
static styles = css`
.add-target-wrapper {
display: flex;
justify-content: flex-start;
margin-top: var(--ha-space-3);
}
ha-generic-picker {
width: 100%;
}
ha-generic-picker {
width: 100%;
}
${unsafeCSS(chipStyles)}
.items {
z-index: 2;
}
.mdc-chip-set {
padding: var(--ha-space-1) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
}
declare global {

View File

@@ -1,66 +1,249 @@
import { TextAreaBase } from "@material/mwc-textarea/mwc-textarea-base";
import { styles as textfieldStyles } from "@material/mwc-textfield/mwc-textfield.css";
import { styles as textareaStyles } from "@material/mwc-textarea/mwc-textarea.css";
import type { PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/textarea/textarea";
import type WaTextarea from "@home-assistant/webawesome/dist/components/textarea/textarea";
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
/**
* Home Assistant textarea component
*
* @element ha-textarea
* @extends {LitElement}
*
* @summary
* A multi-line text input component supporting Home Assistant theming and validation, based on webawesome textarea.
*
* @slot label - Custom label content. Overrides the `label` property.
* @slot hint - Custom hint content. Overrides the `hint` property.
*
* @csspart wa-base - The underlying wa-textarea base wrapper.
* @csspart wa-hint - The underlying wa-textarea hint container.
* @csspart wa-textarea - The underlying wa-textarea textarea element.
*
* @cssprop --ha-textarea-padding-bottom - Padding below the textarea host.
* @cssprop --ha-textarea-max-height - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
* @cssprop --ha-textarea-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
*
* @attr {string} label - The textarea's label text.
* @attr {string} hint - The textarea's hint/helper text.
* @attr {string} placeholder - Placeholder text shown when the textarea is empty.
* @attr {boolean} readonly - Makes the textarea readonly.
* @attr {boolean} disabled - Disables the textarea and prevents user interaction.
* @attr {boolean} required - Makes the textarea a required field.
* @attr {number} rows - Number of visible text rows.
* @attr {number} minlength - Minimum number of characters required.
* @attr {number} maxlength - Maximum number of characters allowed.
* @attr {("none"|"vertical"|"horizontal"|"both"|"auto")} resize - Controls the textarea's resize behavior. Defaults to `"none"`.
* @attr {boolean} auto-validate - Validates the textarea on blur instead of on form submit.
* @attr {boolean} invalid - Marks the textarea as invalid.
* @attr {string} validation-message - Custom validation message shown when the textarea is invalid.
*/
@customElement("ha-textarea")
export class HaTextArea extends TextAreaBase {
@property({ type: Boolean, reflect: true }) autogrow = false;
export class HaTextArea extends WaInputMixin(LitElement) {
@property({ type: Number })
public rows?: number;
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this.autogrow && changedProperties.has("value")) {
this.mdcRoot.dataset.value = this.value + '=\u200B"'; // add a zero-width space to correctly wrap
@property()
public resize: "none" | "vertical" | "horizontal" | "both" | "auto" = "none";
@query("wa-textarea")
private _textarea?: WaTextarea;
private readonly _hasSlotController = new HasSlotController(
this,
"label",
"hint"
);
protected get _formControl(): WaTextarea | undefined {
return this._textarea;
}
protected readonly _requiredMarkerCSSVar = "--ha-textarea-required-marker";
/** Programmatically toggle focus styling (used by ha-date-range-picker). */
public setFocused(focused: boolean): void {
if (focused) {
this.toggleAttribute("focused", true);
} else {
this.removeAttribute("focused");
}
}
static override styles = [
textfieldStyles,
textareaStyles,
protected render() {
const hasLabelSlot = this.label
? false
: this._hasSlotController.test("label");
const hasHintSlot = this.hint
? false
: this._hasSlotController.test("hint");
return html`
<wa-textarea
.value=${this.value ?? null}
.placeholder=${this.placeholder}
.readonly=${this.readonly}
.required=${this.required}
.rows=${this.rows ?? 4}
.resize=${this.resize}
.disabled=${this.disabled}
name=${ifDefined(this.name)}
autocapitalize=${ifDefined(this.autocapitalize || undefined)}
autocomplete=${ifDefined(this.autocomplete)}
.autofocus=${this.autofocus}
.spellcheck=${this.spellcheck}
inputmode=${ifDefined(this.inputmode || undefined)}
enterkeyhint=${ifDefined(this.enterkeyhint || undefined)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
"label-raised":
(this.value !== undefined && this.value !== "") ||
(this.label && this.placeholder),
"no-label": !this.label,
"hint-hidden":
!this.hint &&
!hasHintSlot &&
!this.required &&
!this._invalid &&
!this.invalid,
})}
@input=${this._handleInput}
@change=${this._handleChange}
@blur=${this._handleBlur}
@wa-invalid=${this._handleInvalid}
exportparts="base:wa-base, hint:wa-hint, textarea:wa-textarea"
>
${this.label || hasLabelSlot
? html`<slot name="label" slot="label"
>${this.label
? this._renderLabel(this.label, this.required)
: nothing}</slot
>`
: nothing}
<div
slot="hint"
class=${classMap({
error: this.invalid || this._invalid,
})}
role=${ifDefined(this.invalid || this._invalid ? "alert" : undefined)}
aria-live="polite"
>
${this._invalid || this.invalid
? this.validationMessage || this._textarea?.validationMessage
: this.hint ||
(hasHintSlot ? html`<slot name="hint"></slot>` : nothing)}
</div>
</wa-textarea>
`;
}
static styles = [
waInputStyles,
css`
:host {
--mdc-text-field-fill-color: var(--ha-color-form-background);
display: flex;
align-items: flex-start;
padding-bottom: var(--ha-textarea-padding-bottom);
}
:host([autogrow]) .mdc-text-field {
position: relative;
min-height: 74px;
min-width: 178px;
max-height: 200px;
/* Label styling */
wa-textarea::part(label) {
width: calc(100% - var(--ha-space-2));
background-color: var(--ha-color-form-background);
transition:
all var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
padding-inline-start: var(--ha-space-3);
padding-inline-end: var(--ha-space-3);
margin: var(--ha-space-1) var(--ha-space-1) 0;
padding-top: var(--ha-space-4);
white-space: nowrap;
overflow: hidden;
}
:host([autogrow]) .mdc-text-field:after {
content: attr(data-value);
margin-top: 23px;
margin-bottom: 9px;
line-height: var(--ha-line-height-normal);
min-height: 42px;
padding: 0px 32px 0 16px;
letter-spacing: var(
--mdc-typography-subtitle1-letter-spacing,
0.009375em
);
visibility: hidden;
white-space: pre-wrap;
:host(:focus-within) wa-textarea::part(label),
:host([focused]) wa-textarea::part(label) {
color: var(--primary-color);
}
:host([autogrow]) .mdc-text-field__input {
wa-textarea.label-raised::part(label),
:host(:focus-within) wa-textarea::part(label),
:host([focused]) wa-textarea::part(label) {
padding-top: var(--ha-space-2);
font-size: var(--ha-font-size-xs);
}
wa-textarea.no-label::part(label) {
height: 0;
padding: 0;
}
/* Base styling */
wa-textarea::part(base) {
min-height: 56px;
padding-top: var(--ha-space-6);
padding-bottom: var(--ha-space-2);
}
wa-textarea.no-label::part(base) {
padding-top: var(--ha-space-3);
}
wa-textarea::part(base)::after {
content: "";
position: absolute;
height: calc(100% - 32px);
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
}
:host([autogrow]) .mdc-text-field.mdc-text-field--no-label:after {
margin-top: 16px;
margin-bottom: 16px;
:host(:focus-within) wa-textarea::part(base)::after,
:host([focused]) wa-textarea::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
.mdc-floating-label {
inset-inline-start: 16px !important;
inset-inline-end: initial !important;
transform-origin: var(--float-start) top;
:host(:focus-within) wa-textarea.invalid::part(base)::after,
wa-textarea.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
@media only screen and (min-width: 459px) {
:host([mobile-multiline]) .mdc-text-field__input {
white-space: nowrap;
max-height: 16px;
}
/* Textarea element styling */
wa-textarea::part(textarea) {
padding: 0 var(--ha-space-4);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
}
:host([resize="auto"]) wa-textarea::part(textarea) {
max-height: var(--ha-textarea-max-height, 200px);
overflow-y: auto;
}
wa-textarea:hover::part(base),
wa-textarea:hover::part(label) {
background-color: var(--ha-color-form-background-hover);
}
wa-textarea[disabled]::part(textarea) {
cursor: not-allowed;
}
wa-textarea[disabled]::part(base),
wa-textarea[disabled]::part(label) {
background-color: var(--ha-color-form-background-disabled);
}
`,
];

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

@@ -5,6 +5,7 @@ import {
import { supportsPassiveEventListener } from "@material/mwc-base/utils";
import type { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter";
import { strings } from "@material/top-app-bar/constants";
// eslint-disable-next-line import-x/no-named-as-default
import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation";
import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";

View File

@@ -3,7 +3,7 @@ import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import { showToast } from "../../util/toast";
import "../ha-button";
import "../ha-icon-button";
@@ -59,8 +59,8 @@ export class HaInputCopy extends LitElement {
false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _showMasked = true;
@@ -90,7 +90,7 @@ export class HaInputCopy extends LitElement {
? html`<ha-icon-button
slot="end"
class="toggle-unmasked"
.label=${this.localize(
.label=${this._i18n.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
@@ -101,7 +101,7 @@ export class HaInputCopy extends LitElement {
</div>
<ha-button @click=${this._copy} appearance="plain" size="small">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.localize("ui.common.copy")}
${this.label || this._i18n.localize("ui.common.copy")}
</ha-button>
</div>
`;
@@ -119,7 +119,7 @@ export class HaInputCopy extends LitElement {
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.localize("ui.common.copied_clipboard"),
message: this._i18n.localize("ui.common.copied_clipboard"),
});
}

View File

@@ -5,7 +5,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { localizeContext } from "../../data/context";
import { internationalizationContext } from "../../data/context";
import { haStyle } from "../../resources/styles";
import "../ha-button";
import "../ha-icon-button";
@@ -64,8 +64,8 @@ class HaInputMulti extends LitElement {
public updateOnBlur = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize?: ContextType<typeof localizeContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
protected render() {
return html`
@@ -109,7 +109,7 @@ class HaInputMulti extends LitElement {
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.localize?.("ui.common.remove") ??
this._i18n?.localize("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
@@ -137,10 +137,10 @@ class HaInputMulti extends LitElement {
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.addLabel ??
(this.label
? this.localize?.("ui.components.multi-textfield.add_item", {
? this._i18n?.localize("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.localize?.("ui.common.add")) ??
: this._i18n?.localize("ui.common.add")) ??
"Add"}
</ha-button>
</div>

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